diff --git a/.env.example b/.env.example deleted file mode 100644 index d312d6c..0000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -VITE_API_BASE_URL=http://localhost:8010 -VITE_WS_BASE_URL=ws://localhost:8010 diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index b2861bd..9d0774b 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -109,7 +109,7 @@ jobs: - name: Update deployment image tag run: | - DEPLOYMENT_FILE="flux-repo/dev/bricks/composable-ui/deployment.yaml" + DEPLOYMENT_FILE="flux-repo/dev/composables/composable-ui/deployment.yaml" if [ -f "$DEPLOYMENT_FILE" ]; then sed -i 's|image: kaiohz/pickpro:composable-ui-.*|image: kaiohz/pickpro:composable-ui-${{ steps.sha.outputs.result }}|g' "$DEPLOYMENT_FILE" else @@ -122,6 +122,6 @@ jobs: cd flux-repo git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add dev/bricks/composable-ui/deployment.yaml + git add dev/composables/composable-ui/deployment.yaml git commit -m "Update composable-ui image to ${{ steps.sha.outputs.result }}" || echo "No changes to commit" git push https://x-access-token:${{ secrets.FLUX_REPO_TOKEN }}@github.com/SoluDevTech/flux.git main diff --git a/.gitignore b/.gitignore index 4550653..23af014 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ coverage .env.local .env.*.local .scannerwork + +# Config +public/config.json + + +trivy-report.json diff --git a/README.md b/README.md index 0dffaac..01f6f06 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ React frontend for interacting with the [composable-agents](https://github.com/s - **Real-time chat** -- Stream AI responses via SSE (Server-Sent Events) - **Human-in-the-loop** -- Review and approve/reject tool calls before execution - **Thread history** -- Conversation threads grouped by agent in a sidebar +- **RAG file browser** -- Browse MinIO folders and files with breadcrumb navigation and file metadata display - **Material Design 3** -- Inspired design system with shadcn/ui components ## Tech Stack @@ -25,6 +26,7 @@ React frontend for interacting with the [composable-agents](https://github.com/s - [Bun](https://bun.sh/) >= 1.0 - [composable-agents](https://github.com/soludev/bricks/composable-agents) API running on port 8010 +- [mcp-raganything](https://github.com/soludev/bricks/mcp-raganything) API running on port 8020 ## Installation @@ -34,12 +36,23 @@ bun install ## Configuration -| Variable | Default | Description | +Configuration is loaded at runtime from `public/config.json`. Copy the example file to get started: + +```bash +cp public/config.example.json public/config.json +``` + +**`public/config.json`** - Application configuration: + +| Field | Type | Description | |---|---|---| -| `VITE_API_BASE_URL` | `http://localhost:8010` | composable-agents API URL | -| `VITE_WS_BASE_URL` | `ws://localhost:8010` | WebSocket URL for streaming | +| `apiBaseUrl` | `string` | composable-agents API URL (e.g., `http://localhost:8010`) | +| `ragApiBaseUrl` | `string` (optional) | RAG API URL for MinIO file browsing (e.g., `http://localhost:8020`). Defaults to `apiBaseUrl` if not set. | +| `wsBaseUrl` | `string` | WebSocket URL for streaming (e.g., `ws://localhost:8010`) | + +The config is validated with Zod on startup. Invalid configuration will show an error toast. -The Vite dev server proxies `/api` requests to the API automatically (see `vite.config.ts`). +**Note:** `config.json` is gitignored. Use `config.example.json` as a template. ## Running @@ -103,30 +116,45 @@ src/ entities/ agent/ # AgentConfig, AgentConfigMetadata, McpServerConfig chat/ # Message, Thread, ChatRequest + config/ # AppConfig (Zod-validated) + rag/ # FileEntry, FolderEntry ports/ agent/agentPort.ts # Agent repository interface chat/chatPort.ts # Chat repository interface + config/configRepository.ts # Config repository interface + rag/ragFilePort.ts # RAG file port interface infrastructure/ # External adapters (API clients, config) api/ agent/agentApi.ts # Agent API adapter (axios) chat/chatApi.ts # Chat API adapter (axios + SSE) + rag/ragApi.ts # RAG API adapter (axios) axiosInstance.ts # Shared axios instance - config/envConfig.ts # Environment variables + ragAxiosInstance.ts # Separate axios client for RAG API + config/ + configRepositoryInstance.ts # Singleton config repository + fileConfigRepository.ts # File-based config implementation application/ # React UI layer components/ agent/ # AgentCard, AgentGrid, CreateAgentDialog, AgentConfigViewer chat/ # ChatInput, ChatMessage, HITLReviewPanel, MessageList layout/ # MainLayout, ThreadSidebar, TopNav + rag/ # BreadcrumbBar, FileList, FileRow, FolderRow shared/ # StatusBadge, ToolTag ui/ # shadcn/ui primitives hooks/ agent/ # useAgents, useCreateAgent, useDeleteAgent, useUpdateAgent, useAgentConfig chat/ # useThreads, useCreateThread, useDeleteThread, useMessages, useSendMessage, useStreamChat + config/ # useConfig + rag/ # useFolders, useFiles pages/ AgentsPage.tsx # /agents route ChatPage.tsx # /chat/:threadId? route + RagPage.tsx # /rag route stores/ useChatStore.ts # Zustand store for chat state +public/ + config.example.json # Example config (committed) + config.json # Runtime config (gitignored) tests/ unit/ # Mirrors src/ structure fixtures/ # Test data @@ -140,6 +168,7 @@ tests/ | `/` | -- | Redirects to `/chat` | | `/agents` | AgentsPage | List, create, view, and delete agents | | `/chat/:threadId?` | ChatPage | Chat with agents, streaming responses, HITL validation | +| `/rag` | RagPage | Browse MinIO folders and files with breadcrumb navigation | ## CI/CD diff --git a/bun.lock b/bun.lock index 70fcfef..a2ec038 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.96.2", - "axios": "^1.14.0", + "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -468,7 +468,7 @@ "autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="], - "axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="], + "axios": ["axios@1.15.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], diff --git a/package.json b/package.json index 5d0698c..0c427c3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.96.2", - "axios": "^1.14.0", + "axios": "^1.15.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", diff --git a/public/config.example.json b/public/config.example.json new file mode 100644 index 0000000..cef40ac --- /dev/null +++ b/public/config.example.json @@ -0,0 +1,5 @@ +{ + "apiBaseUrl": "http://localhost:8010", + "wsBaseUrl": "ws://localhost:8010", + "ragApiBaseUrl": "http://localhost:8020" +} \ No newline at end of file diff --git a/src/application/App.tsx b/src/application/App.tsx index 790e1f0..12671ae 100644 --- a/src/application/App.tsx +++ b/src/application/App.tsx @@ -1,6 +1,7 @@ import { Routes, Route, Navigate } from "react-router-dom"; import AgentsPage from "@/application/pages/AgentsPage"; import ChatPage from "@/application/pages/ChatPage"; +import RagPage from "@/application/pages/RagPage"; function App() { return ( @@ -8,6 +9,7 @@ function App() { } /> } /> } /> + } /> ); } diff --git a/src/application/components/agent/AgentCard.tsx b/src/application/components/agent/AgentCard.tsx index 0f0c603..f673b52 100644 --- a/src/application/components/agent/AgentCard.tsx +++ b/src/application/components/agent/AgentCard.tsx @@ -40,7 +40,10 @@ function getAgentIcon(name: string): string { return AGENT_ICONS[firstLetter] ?? "smart_toy"; } -export default function AgentCard({ agent, onConfigure }: Readonly) { +export default function AgentCard({ + agent, + onConfigure, +}: Readonly) { return (
{/* Header: icon + status */} diff --git a/src/application/components/agent/AgentConfigViewer.tsx b/src/application/components/agent/AgentConfigViewer.tsx index fbe4661..a96e8be 100644 --- a/src/application/components/agent/AgentConfigViewer.tsx +++ b/src/application/components/agent/AgentConfigViewer.tsx @@ -49,205 +49,221 @@ export default function AgentConfigViewer({ } return ( - { if (e.key === "Escape") onOpenChange(false); }} + onKeyDown={(e) => { + if (e.key === "Escape") onOpenChange(false); + }} + role="dialog" + aria-modal="true" + aria-labelledby="agent-viewer-title" > -
- {/* Header */} -
-

- {agentName} -

- -
- - {isLoading && ( -

- Loading configuration... -

- )} +
e.stopPropagation()} + > +
+ {/* Header */} +
+

+ {agentName} +

+ +
- {config && ( -
- {/* 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 ( - { if (e.key === "Escape") onOpenChange(false); }} + onKeyDown={(e) => { + if (e.key === "Escape") onOpenChange(false); + }} + role="dialog" + aria-modal="true" + aria-labelledby="create-agent-title" > -
- {/* Header */} -
-

- Create Agent -

- -
- - {/* Form */} -
-
-
-
+ ); } diff --git a/src/application/components/chat/ChatInput.tsx b/src/application/components/chat/ChatInput.tsx index 2a41508..abe2774 100644 --- a/src/application/components/chat/ChatInput.tsx +++ b/src/application/components/chat/ChatInput.tsx @@ -1,5 +1,6 @@ import { useState, type KeyboardEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { cn } from "@/application/lib/utils"; import { useStreamChat } from "@/application/hooks/chat/useStreamChat"; import { useSendMessage } from "@/application/hooks/chat/useSendMessage"; @@ -31,16 +32,17 @@ export default function ChatInput({ threadId }: Readonly) { sendMessage.mutate( { message: trimmed }, { - onSuccess: () => { - useChatStore.getState().setPendingUserMessage(null); - useChatStore.getState().setStreaming(false); - queryClient.invalidateQueries({ + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["messages", threadId], }); + useChatStore.getState().setPendingUserMessage(null); + useChatStore.getState().setStreaming(false); }, onError: () => { useChatStore.getState().setPendingUserMessage(null); useChatStore.getState().setStreaming(false); + toast.error("Failed to send message"); }, }, ); diff --git a/src/application/components/chat/MessageList.tsx b/src/application/components/chat/MessageList.tsx index 90b27d2..bfcfcba 100644 --- a/src/application/components/chat/MessageList.tsx +++ b/src/application/components/chat/MessageList.tsx @@ -11,7 +11,10 @@ interface MessageListProps { agentName: string; } -export default function MessageList({ threadId, agentName }: Readonly) { +export default function MessageList({ + threadId, + agentName, +}: Readonly) { const { data: messages, isLoading } = useMessages(threadId); const { streamingContent, isStreaming, pendingUserMessage } = useChatStore(); const scrollRef = useRef(null); diff --git a/src/application/components/layout/ThreadSidebar.tsx b/src/application/components/layout/ThreadSidebar.tsx index 3e1b297..95e9887 100644 --- a/src/application/components/layout/ThreadSidebar.tsx +++ b/src/application/components/layout/ThreadSidebar.tsx @@ -21,7 +21,9 @@ function formatDate(dateStr: string): string { }); } -export default function ThreadSidebar({ activeThreadId }: Readonly) { +export default function ThreadSidebar({ + activeThreadId, +}: Readonly) { const { data: threads, isLoading } = useThreads(); const { data: agents, isLoading: agentsLoading } = useAgents(); const createThread = useCreateThread(); @@ -68,8 +70,8 @@ export default function ThreadSidebar({ activeThreadId }: Readonly - add - {" "}New Conversation + add New + Conversation @@ -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 ( +
+

+ Loading files... +

+
+ ); + } + + if (error) { + return ( +
+ +

+ Unable to load files. Please try again. +

+ +
+ ); + } + + if (folders.length === 0 && files.length === 0) { + return ( +
+ +

+ 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 ( +
+ + + {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,