add rag-view frontend

This commit is contained in:
2026-02-27 20:50:03 +01:00
parent f20c422f01
commit 65ade24e1b
80 changed files with 9923 additions and 1 deletions

View File

@@ -205,7 +205,6 @@ deploy-rag:
ENDSSH ENDSSH
deploy-gateway: deploy-gateway:
<<: *deploy_setup
<<: *deploy_setup <<: *deploy_setup
needs: [publish-gateway] needs: [publish-gateway]
script: script:

26
rag-view/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
.git
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
rag-view/README.md Normal file
View File

@@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@@ -0,0 +1,11 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html/ragview
EXPOSE 80

View File

@@ -0,0 +1,8 @@
server {
listen 80;
root /usr/share/nginx/html;
location /ragview/ {
try_files $uri $uri/ /ragview/index.html;
}
}

29
rag-view/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
rag-view/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RAG by Balex</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4601
rag-view/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
rag-view/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "rag-view",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.10.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.0.2",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"tailwindcss-textshadow": "^2.1.3"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4.1.18",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"autoprefixer": "^10.4.23",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.4.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.1.7"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,31 @@
Example Questions and Expected Answers
=======================================
These questions are based on the default context file (guest-example.txt).
Use them to test RAG query behavior with different settings.
--- Questions with answers found in context ---
1. What port does the Notification Service run on?
Expected answer: 8082
2. How long are access tokens valid in Project Aurora?
Expected answer: 30 minutes
3. What is the database naming convention?
Expected answer: aurora_{service_name}
4. Who is the lead developer?
Expected answer: Maria Chen
5. What message broker does Aurora use?
Expected answer: RabbitMQ on port 5672
6. What happens when CPU goes above 70%?
Expected answer: HPA scales pods, from 2 to 10 replicas
--- Question to test onlyContext mode ---
7. What is the capital of France?
With onlyContext=true: should respond that the answer is not found in context
With onlyContext=false: should answer "Paris"

View File

@@ -0,0 +1,30 @@
Project Aurora - Internal Technical Documentation
Project Aurora is an internal microservices platform developed by NovaTech Solutions
in Q3 2024. The platform consists of five core services: Gateway Service (port 8080),
User Management Service (port 8081), Notification Service (port 8082), Analytics
Service (port 8083), and Billing Service (port 8084).
The Gateway Service uses Spring Cloud Gateway and requires a minimum of 512MB RAM.
All inter-service communication is handled via RabbitMQ running on port 5672.
The default exchange name is "aurora.exchange" and the dead letter queue is
"aurora.dlq".
Database configuration: each service has its own PostgreSQL schema. The naming
convention is aurora_{service_name}. For example, the User Management Service
uses the schema aurora_user_management. Connection pooling is managed by HikariCP
with a maximum pool size of 15 connections per service.
Authentication is handled by the User Management Service using JWT tokens with
RS256 algorithm. Access tokens expire after 30 minutes, refresh tokens after 7 days.
The public key for token verification is available at
http://user-service:8081/.well-known/jwks.json.
Deployment: all services are containerized using Docker and orchestrated with
Kubernetes. The production cluster runs on 3 nodes with a minimum of 8GB RAM each.
Horizontal Pod Autoscaler is configured to scale between 2 and 10 replicas based
on CPU utilization threshold of 70%.
The lead developer is Maria Chen (maria.chen@novatech.example.com).
The project manager is Alex Kumar (alex.kumar@novatech.example.com).
Weekly sync meetings are held every Wednesday at 14:00 CET.

50
rag-view/src/App.jsx Normal file
View File

@@ -0,0 +1,50 @@
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import fetchUserProfile from "./features/fetch-async/fetchUserProfile";
import { TOKEN_UNDEFINED } from "./features/constants";
import RootLayout from "./pages/RootLayout";
import HomePage from "./pages/HomePage";
import RegisterPage from "./pages/RegisterPage";
import LoginPage from "./pages/LoginPage";
import UploadPage from "./pages/UploadPage";
import RagPage from "./pages/RagPage";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "upload", element: <UploadPage /> },
{ path: "rag", element: <RagPage /> },
],
},
]);
function App() {
const dispatch = useDispatch();
const token = useSelector((state) => state.userDetails.token);
const bootstrappedRef = useRef(false);
const auth =
typeof token === "string" &&
token !== TOKEN_UNDEFINED &&
token.trim() !== "";
useEffect(() => {
if (!auth) {
bootstrappedRef.current = false;
return;
}
if (bootstrappedRef.current) return;
bootstrappedRef.current = true;
dispatch(fetchUserProfile());
}, [auth, dispatch]);
return <RouterProvider router={router} />;
}
export default App;

View File

@@ -0,0 +1,54 @@
export const BASE_URL = `${import.meta.env.VITE_API_BASE_URL}/api/rag`;
export const METHOD_POST_QUERY = "POST";
export const METHOD_GET_QUERY = "GET";
export const METHOD_DELETE_QUERY = "DELETE";
export const HEADER_CONTENT_TYPE = "Content-Type";
export const JWT_TOKEN = "jwt";
export const JWT_REFRESH_TOKEN = "jwt-refresh";
export const APPLICATION_JSON = "application/json";
export const USER_NAME_UNDEFINED = "userNameUndefined";
export const USER_EMAIL_UNDEFINED = "userEmailUndefined";
export const GUEST_EMAIL = "guest@gmail.com";
export const GUEST_PASSWORD = "Guest123!";
export const TOKEN_EXPIRED = "Token expired.";
export const ERROR_RESPONSE_NOT_OK = "Response not ok";
export const ERROR_NO_RESPONSE = "No response from server";
export const PREFIX_AUTH = "/auth";
export const PREFIX_USERS = "/users";
export const PREFIX_CHAT = "/chat";
export const PREFIX_ENTRY = "/entry";
export const PREFIX_CREATE_NEW_CHAT = "/new";
export const PREFIX_USERINFO = "/userinfo";
export const PREFIX_REGISTER = "/register";
export const PREFIX_LOGIN = "/login";
export const PREFIX_REFRESH_TOKEN = "/refresh/token";
export const PREFIX_DOCUMENT = "/documents";
export const PREFIX_UPLOAD = "/upload";
export const PREFIX_UPLOAD_STREAM = "/upload-stream";
export const FORM_DATA_FILES = "files";
export const TYPE_WINDOW_UNDEFINED = "undefined";
export const TOKEN_UNDEFINED = "undefined";
export const MAX_FILE_SIZE_KB = 10;
export const MAX_FILES_TO_UPLOAD = 3;
export const DEFAULT_ERROR_STATUS = 500;
export const FULFILLED_BUT_NOT_SUCCESS_ERROR_STATUS = 999;
export const UNKNOWN_ERROR_AFTER_FULFILLED_QUERY =
"Unknown error after fulfilled query";
export const UNKNOWN_ERROR = "Unknown error";
export const STATUS_UNAUTHORIZED = "unauthorized";
export const HTTP_STATUS_UNAUTHORIZED_CODE = 401;
export const NO_REFRESH_TOKEN = "No refresh token";
export const PROGRESS_STATUS_PROCESSING = "processing";
export const PROGRESS_STATUS_COMPLETED = "completed";
export const PROGRESS_STATUS_ERROR = "error";
export const PROGRESS_STATUS_SKIPPED = "skipped";
export const IS_SEARCH_RESULT_ONLY_WITH_CONTEXT_DEFAULT = true;
export const TOP_P_FAST_VALUE = 0.5;
export const TOP_P_DEFAULT_VALUE = 0.6;
export const TOP_P_SLOW_VALUE = 0.9;
export const NO_ACTIVE_CHAT_ID = 0;
export const MAX_TITLE_LENGTH = 30;
export const MESSAGE_ROLE = {
USER: "user",
ASSISTANT: "assistant",
};

View File

@@ -0,0 +1,44 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import {
BASE_URL,
PREFIX_ENTRY,
METHOD_POST_QUERY,
STATUS_UNAUTHORIZED,
UNKNOWN_ERROR,
HTTP_STATUS_UNAUTHORIZED_CODE,
} from "../constants";
const fetchAddNewUserEntry = createAsyncThunk(
"chat/fetchAddNewUserEntry",
async ({ chatId, content, onlyContext, topP }, thunkAPI) => {
const { rejectWithValue } = thunkAPI;
try {
const res = await fetchWithAuth(
`${BASE_URL}${PREFIX_ENTRY}/${chatId}`,
{
method: METHOD_POST_QUERY,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, onlyContext, topP }),
},
thunkAPI,
);
if (res.status === HTTP_STATUS_UNAUTHORIZED_CODE) {
return rejectWithValue({ status: STATUS_UNAUTHORIZED });
}
if (!res.ok) {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
const data = await res.json();
return data;
} catch {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
},
);
export default fetchAddNewUserEntry;

View File

@@ -0,0 +1,43 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import {
BASE_URL,
PREFIX_CHAT,
PREFIX_CREATE_NEW_CHAT,
METHOD_POST_QUERY,
STATUS_UNAUTHORIZED,
UNKNOWN_ERROR,
HTTP_STATUS_UNAUTHORIZED_CODE,
} from "../constants";
const fetchCreateNewChat = createAsyncThunk(
"chat/fetchCreateNewChat",
async (title, thunkAPI) => {
const { rejectWithValue } = thunkAPI;
try {
const res = await fetchWithAuth(
`${BASE_URL}${PREFIX_CHAT}${PREFIX_CREATE_NEW_CHAT}?title=${encodeURIComponent(
title,
)}`,
{ method: METHOD_POST_QUERY },
thunkAPI,
);
if (res.status === HTTP_STATUS_UNAUTHORIZED_CODE) {
return rejectWithValue({ status: STATUS_UNAUTHORIZED });
}
if (!res.ok) {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
const data = await res.json();
return data;
} catch {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
},
);
export default fetchCreateNewChat;

View File

@@ -0,0 +1,50 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import {
BASE_URL,
PREFIX_CHAT,
METHOD_DELETE_QUERY,
STATUS_UNAUTHORIZED,
UNKNOWN_ERROR,
HTTP_STATUS_UNAUTHORIZED_CODE,
} from "../constants";
const fetchDeleteChat = createAsyncThunk(
"chat/fetchDeleteChat",
async (chatId, thunkAPI) => {
const { rejectWithValue } = thunkAPI;
try {
const res = await fetchWithAuth(
`${BASE_URL}${PREFIX_CHAT}/${chatId}`,
{ method: METHOD_DELETE_QUERY },
thunkAPI,
);
if (res.status === HTTP_STATUS_UNAUTHORIZED_CODE) {
return rejectWithValue({ status: STATUS_UNAUTHORIZED });
}
if (!res.ok) {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
// DELETE requests may return 204 No Content without a body
if (res.status === 204) {
return { success: true, chatId };
}
try {
const data = await res.json();
return data;
} catch {
// If JSON parsing fails, still consider it a success
return { success: true, chatId };
}
} catch {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
},
);
export default fetchDeleteChat;

View File

@@ -0,0 +1,33 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import {
BASE_URL,
PREFIX_USERS,
PREFIX_USERINFO,
METHOD_DELETE_QUERY,
ERROR_RESPONSE_NOT_OK,
} from "../constants";
const fetchDeleteUploaded = createAsyncThunk(
"userDetails/fetchDeleteUploaded",
async (_, thunkAPI) => {
const res = await fetchWithAuth(
BASE_URL + PREFIX_USERS + PREFIX_USERINFO,
{ method: METHOD_DELETE_QUERY },
thunkAPI
);
if (!res.ok) {
const text = await res.text().catch(() => "");
return thunkAPI.rejectWithValue({
status: res.status,
message: text || ERROR_RESPONSE_NOT_OK,
});
}
const data = await res.json();
return data;
}
);
export default fetchDeleteUploaded;

View File

@@ -0,0 +1,40 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import {
BASE_URL,
PREFIX_CHAT,
METHOD_GET_QUERY,
STATUS_UNAUTHORIZED,
UNKNOWN_ERROR,
HTTP_STATUS_UNAUTHORIZED_CODE,
} from "../constants";
const fetchGetChat = createAsyncThunk(
"chat/fetchGetChat",
async (chatId, thunkAPI) => {
const { rejectWithValue } = thunkAPI;
try {
const res = await fetchWithAuth(
`${BASE_URL}${PREFIX_CHAT}/${chatId}`,
{ method: METHOD_GET_QUERY },
thunkAPI,
);
if (res.status === HTTP_STATUS_UNAUTHORIZED_CODE) {
return rejectWithValue({ status: STATUS_UNAUTHORIZED });
}
if (!res.ok) {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
const data = await res.json();
return data;
} catch {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
},
);
export default fetchGetChat;

View File

@@ -0,0 +1,40 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import {
BASE_URL,
PREFIX_CHAT,
METHOD_GET_QUERY,
STATUS_UNAUTHORIZED,
UNKNOWN_ERROR,
HTTP_STATUS_UNAUTHORIZED_CODE,
} from "../constants";
const fetchGetChatList = createAsyncThunk(
"chat/fetchGetChatList",
async (_, thunkAPI) => {
const { rejectWithValue } = thunkAPI;
try {
const res = await fetchWithAuth(
`${BASE_URL}${PREFIX_CHAT}`,
{ method: METHOD_GET_QUERY },
thunkAPI,
);
if (res.status === HTTP_STATUS_UNAUTHORIZED_CODE) {
return rejectWithValue({ status: STATUS_UNAUTHORIZED });
}
if (!res.ok) {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
const data = await res.json();
return data;
} catch {
return rejectWithValue({ status: UNKNOWN_ERROR });
}
},
);
export default fetchGetChatList;

View File

@@ -0,0 +1,54 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import {
BASE_URL,
METHOD_POST_QUERY,
HEADER_CONTENT_TYPE,
PREFIX_AUTH,
PREFIX_LOGIN,
APPLICATION_JSON,
ERROR_RESPONSE_NOT_OK,
ERROR_NO_RESPONSE,
} from "../constants";
const fetchLoginUser = createAsyncThunk(
"userDetails/fetchLoginUser",
async (payload, { rejectWithValue }) => {
try {
const res = await fetch(BASE_URL + PREFIX_AUTH + PREFIX_LOGIN, {
method: METHOD_POST_QUERY,
headers: { [HEADER_CONTENT_TYPE]: APPLICATION_JSON },
body: JSON.stringify(payload),
});
if (!res.ok) {
// Проверяем Content-Type ответа
const contentType = res.headers.get("Content-Type") || "";
let errorMessage = ERROR_RESPONSE_NOT_OK;
if (contentType.includes("application/json")) {
const errorData = await res.json().catch(() => ({}));
errorMessage = errorData.message || ERROR_RESPONSE_NOT_OK;
} else {
// text/plain ответ
const textError = await res.text().catch(() => "");
errorMessage = textError || ERROR_RESPONSE_NOT_OK;
}
return rejectWithValue({
status: res.status,
message: errorMessage,
});
}
const data = await res.json();
return data;
} catch (err) {
return rejectWithValue({
status: 500,
message: `${ERROR_NO_RESPONSE}: ${err}`,
});
}
}
);
export default fetchLoginUser;

View File

@@ -0,0 +1,54 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import {
BASE_URL,
METHOD_GET_QUERY,
PREFIX_AUTH,
PREFIX_REFRESH_TOKEN,
TOKEN_UNDEFINED,
JWT_REFRESH_TOKEN,
ERROR_RESPONSE_NOT_OK,
ERROR_NO_RESPONSE,
NO_REFRESH_TOKEN,
} from "../constants";
const fetchRefreshToken = createAsyncThunk(
"userDetails/fetchRefreshToken",
async (_, { getState, rejectWithValue }) => {
try {
const state = getState();
const refreshToken =
state.userDetails?.refreshToken ||
localStorage.getItem(JWT_REFRESH_TOKEN);
if (!refreshToken || refreshToken === TOKEN_UNDEFINED) {
return rejectWithValue({ status: 401, message: NO_REFRESH_TOKEN });
}
const url =
BASE_URL +
PREFIX_AUTH +
PREFIX_REFRESH_TOKEN +
`?token=${encodeURIComponent(refreshToken)}`;
const res = await fetch(url, { method: METHOD_GET_QUERY });
if (!res.ok) {
const text = await res.text().catch(() => "");
return rejectWithValue({
status: res.status,
message: text || ERROR_RESPONSE_NOT_OK,
});
}
const data = await res.json();
return data;
} catch (err) {
return rejectWithValue({
status: 500,
message: `${ERROR_NO_RESPONSE}: ${err}`,
});
}
}
);
export default fetchRefreshToken;

View File

@@ -0,0 +1,52 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import {
BASE_URL,
METHOD_POST_QUERY,
HEADER_CONTENT_TYPE,
PREFIX_AUTH,
PREFIX_REGISTER,
APPLICATION_JSON,
ERROR_RESPONSE_NOT_OK,
ERROR_NO_RESPONSE,
} from "../constants";
const fetchRegisterUser = createAsyncThunk(
"userDetails/fetchRegisterUser",
async (payload, { rejectWithValue }) => {
try {
const res = await fetch(BASE_URL + PREFIX_AUTH + PREFIX_REGISTER, {
method: METHOD_POST_QUERY,
headers: { [HEADER_CONTENT_TYPE]: APPLICATION_JSON },
body: JSON.stringify(payload),
});
if (!res.ok) {
const contentType = res.headers.get("Content-Type") || "";
let errorMessage = ERROR_RESPONSE_NOT_OK;
if (contentType.includes("application/json")) {
const errorData = await res.json().catch(() => ({}));
errorMessage = errorData.message || ERROR_RESPONSE_NOT_OK;
} else {
const textError = await res.text().catch(() => "");
errorMessage = textError || ERROR_RESPONSE_NOT_OK;
}
return rejectWithValue({
status: res.status,
message: errorMessage,
});
}
const data = await res.json();
return data;
} catch (err) {
return rejectWithValue({
status: 500,
message: `${ERROR_NO_RESPONSE}: ${err}`,
});
}
}
);
export default fetchRegisterUser;

View File

@@ -0,0 +1,35 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchWithAuth } from "./fetchWithAuth";
import { logoutUser } from "../slices/details-slice";
import {
BASE_URL,
PREFIX_USERS,
PREFIX_USERINFO,
METHOD_GET_QUERY,
} from "../constants";
const fetchUserProfile = createAsyncThunk(
"userDetails/fetchUserProfile",
async (_, thunkAPI) => {
try {
const res = await fetchWithAuth(
BASE_URL + PREFIX_USERS + PREFIX_USERINFO,
{ method: METHOD_GET_QUERY },
thunkAPI,
);
const text = await res.text().catch(() => "");
if (!res.ok) {
thunkAPI.dispatch(logoutUser());
return;
}
const data = JSON.parse(text);
return data;
} catch {
thunkAPI.dispatch(logoutUser());
}
},
);
export default fetchUserProfile;

View File

@@ -0,0 +1,46 @@
import fetchRefreshToken from "./fetchRefreshToken";
import { JWT_TOKEN } from "../constants";
const SESSION_INVALIDATED = "SESSION_INVALIDATED";
export const fetchWithAuth = async (
url,
options = {},
{ dispatch, getState },
) => {
const state = getState();
const token = state.userDetails?.token || localStorage.getItem(JWT_TOKEN);
const headers = {
...(options.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const firstRes = await fetch(url, { ...options, headers });
if (firstRes.status !== 401) return firstRes;
const responseText = await firstRes.clone().text();
if (responseText === SESSION_INVALIDATED) {
localStorage.clear();
window.location.href = "/login";
return firstRes;
}
try {
await dispatch(fetchRefreshToken()).unwrap();
} catch {
return firstRes;
}
const newToken =
getState().userDetails?.token || localStorage.getItem(JWT_TOKEN);
const retryHeaders = {
...(options.headers || {}),
...(newToken ? { Authorization: `Bearer ${newToken}` } : {}),
};
return fetch(url, { ...options, headers: retryHeaders });
};

View File

@@ -0,0 +1,24 @@
import fetchDeleteChat from "../../fetch-async/fetchDeleteChat";
export const buildDeleteChatExtraReducers = (builder) => {
builder
.addCase(fetchDeleteChat.fulfilled, (state, action) => {
const deletedChatId = action.meta.arg;
state.chatList = state.chatList.filter((chat) => chat.id !== deletedChatId);
if (state.activeChatId === deletedChatId) {
if (state.chatList.length > 0) {
state.activeChatId = state.chatList[0].id;
state.activeTitle = state.chatList[0].title;
} else {
state.activeChatId = null;
state.activeTitle = "";
state.messages = [];
}
}
})
.addCase(fetchDeleteChat.rejected, (_state, action) => {
console.log("fetchDeleteChat error:", action.payload?.status);
});
};

View File

@@ -0,0 +1,15 @@
import fetchGetChat from "../../fetch-async/fetchGetChat";
export const buildGetChatExtraReducers = (builder) => {
builder
.addCase(fetchGetChat.fulfilled, (state, action) => {
const chat = action.payload;
if (chat) {
state.messages = chat.history || [];
}
})
.addCase(fetchGetChat.rejected, (_state, action) => {
console.log("fetchGetChat error:", action.payload?.status);
});
};

View File

@@ -0,0 +1,16 @@
import fetchGetChatList from "../../fetch-async/fetchGetChatList";
export const buildChatListExtraReducers = (builder) => {
builder
.addCase(fetchGetChatList.fulfilled, (state, action) => {
const chatList = action.payload;
if (chatList && chatList.length > 0) {
state.chatList = chatList;
state.messages = [];
}
})
.addCase(fetchGetChatList.rejected, (state, action) => {
console.log("fetchGetChatList error:", action.payload?.status);
});
};

View File

@@ -0,0 +1,26 @@
import { createSlice } from "@reduxjs/toolkit";
import { reducers } from "./reducers";
import { initialState } from "./initialState";
import { buildNewChatExtraReducers } from "./newChatExtraReducers";
import { buildChatListExtraReducers } from "./getChatListExtraReducers";
import { buildDeleteChatExtraReducers } from "./deleteChatExtraReducers";
import { buildNewEntryExtraReducers } from "./newEntryExtraReducers";
import { buildGetChatExtraReducers } from "./getChatExtraReducers";
const chatSlice = createSlice({
name: "chats",
initialState,
reducers,
extraReducers: (builder) => {
buildNewChatExtraReducers(builder);
buildChatListExtraReducers(builder);
buildDeleteChatExtraReducers(builder);
buildNewEntryExtraReducers(builder);
buildGetChatExtraReducers(builder);
},
});
export const { setActiveChat } = chatSlice.actions;
export default chatSlice.reducer;

View File

@@ -0,0 +1,9 @@
import { NO_ACTIVE_CHAT_ID } from "../../constants";
export const initialState = {
chatList: [],
activeChatId: NO_ACTIVE_CHAT_ID,
activeTitle: "",
messages: [],
isWaitingResponse: false,
};

View File

@@ -0,0 +1,18 @@
import fetchCreateNewChat from "../../fetch-async/fetchCreateNewChat";
export const buildNewChatExtraReducers = (builder) => {
builder
.addCase(fetchCreateNewChat.fulfilled, (state, action) => {
const chat = action.payload;
if (chat) {
state.chatList.push(chat);
state.activeChatId = chat.id;
state.activeTitle = chat.title;
state.messages = [];
}
})
.addCase(fetchCreateNewChat.rejected, (state, action) => {
console.log("fetchCreateNewChat error:", action.payload?.status);
});
};

View File

@@ -0,0 +1,18 @@
import fetchAddNewUserEntry from "../../fetch-async/fetchAddNewUserEntry";
import { MESSAGE_ROLE } from "../../constants";
export const buildNewEntryExtraReducers = (builder) => {
builder
.addCase(fetchAddNewUserEntry.pending, (state, action) => {
const { content } = action.meta.arg;
state.messages.push({ role: MESSAGE_ROLE.USER, content });
state.isWaitingResponse = true;
})
.addCase(fetchAddNewUserEntry.fulfilled, (state) => {
state.isWaitingResponse = false;
})
.addCase(fetchAddNewUserEntry.rejected, (state, action) => {
state.isWaitingResponse = false;
console.log("fetchAddNewUserEntry error:", action.payload?.status);
});
};

View File

@@ -0,0 +1,8 @@
export const reducers = {
setActiveChat(state, action) {
const chat = action.payload;
state.activeChatId = chat.id;
state.activeTitle = chat.title;
state.messages = chat.history || [];
},
};

View File

@@ -0,0 +1,59 @@
import fetchLoginUser from "../../fetch-async/fetchLoginUser";
import fetchRegisterUser from "../../fetch-async/fetchRegisterUser";
import fetchRefreshToken from "../../fetch-async/fetchRefreshToken";
import { updateTokens } from "./tokenHelpers";
import { UNKNOWN_ERROR } from "../../constants";
export const buildAuthExtraReducers = (builder) => {
// Register
builder.addCase(fetchRegisterUser.pending, (state) => {
state.loading = true;
state.error = null;
});
builder.addCase(fetchRegisterUser.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
const p = action.payload.payload;
state.userName = p.username;
state.userEmail = p.email;
updateTokens(state, p.token, p.refreshToken);
});
builder.addCase(fetchRegisterUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.status ?? 500;
});
// Login
builder.addCase(fetchLoginUser.pending, (state) => {
state.loading = true;
state.error = null;
});
builder.addCase(fetchLoginUser.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
const p = action.payload.payload;
state.userName = p.username;
state.userEmail = p.email;
updateTokens(state, p.token, p.refreshToken);
});
builder.addCase(fetchLoginUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? { status: 500, message: UNKNOWN_ERROR };
});
// Refresh Token
builder.addCase(fetchRefreshToken.fulfilled, (state, action) => {
const p = action.payload?.payload;
updateTokens(state, p?.token, p?.refreshToken);
});
builder.addCase(fetchRefreshToken.rejected, (state, action) => {
state.error = action.payload ?? { status: 500, message: UNKNOWN_ERROR };
});
};

View File

@@ -0,0 +1,25 @@
import fetchDeleteUploaded from "../../fetch-async/fetchDeleteUploaded";
import { UNKNOWN_ERROR } from "../../constants";
export const buildDeleteExtraReducers = (builder) => {
builder.addCase(fetchDeleteUploaded.pending, (state) => {
state.deleting = true;
state.deleteSuccess = false;
state.deletedCount = 0;
state.error = null;
});
builder.addCase(fetchDeleteUploaded.fulfilled, (state, action) => {
state.deleting = false;
state.deleteSuccess = true;
state.deletedCount = action.payload?.payload ?? 0;
state.loadedFiles = [];
state.error = null;
});
builder.addCase(fetchDeleteUploaded.rejected, (state, action) => {
state.deleting = false;
state.deleteSuccess = false;
state.error = action.payload ?? { status: 500, message: UNKNOWN_ERROR };
});
};

View File

@@ -0,0 +1,7 @@
export {
logoutUser,
clearError,
clearDeleteStatus,
//addLoadedFiles,
default,
} from "./userDetailsSlice";

View File

@@ -0,0 +1,36 @@
import {
USER_NAME_UNDEFINED,
USER_EMAIL_UNDEFINED,
JWT_TOKEN,
JWT_REFRESH_TOKEN,
TYPE_WINDOW_UNDEFINED,
TOKEN_UNDEFINED,
} from "../../constants";
const getInitialToken = () => {
if (typeof window === TYPE_WINDOW_UNDEFINED) return null;
const token = localStorage.getItem(JWT_TOKEN);
if (!token || token === TOKEN_UNDEFINED) return null;
return token;
};
const getInitialRefreshToken = () => {
if (typeof window === TYPE_WINDOW_UNDEFINED) return null;
return localStorage.getItem(JWT_REFRESH_TOKEN);
};
export const initialState = {
userId: null,
userName: USER_NAME_UNDEFINED,
userEmail: USER_EMAIL_UNDEFINED,
loadedFiles: [],
maxFilesToLoad: 0,
token: getInitialToken(),
refreshToken: getInitialRefreshToken(),
loading: false,
deleting: false,
deleteSuccess: false,
deletedCount: 0,
error: null,
};

View File

@@ -0,0 +1,27 @@
import fetchUserProfile from "../../fetch-async/fetchUserProfile";
import { UNKNOWN_ERROR } from "../../constants";
export const buildProfileExtraReducers = (builder) => {
builder.addCase(fetchUserProfile.pending, (state) => {
state.loading = true;
state.error = null;
});
builder.addCase(fetchUserProfile.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
const p = action.payload?.payload;
if (p?.id) state.userId = p.id;
if (p?.username) state.userName = p.username;
if (p?.email) state.userEmail = p.email;
if (p?.loadedFiles) state.loadedFiles = p.loadedFiles;
if (p?.maxLoadedFiles != null) state.maxFilesToLoad = p.maxLoadedFiles;
});
builder.addCase(fetchUserProfile.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? { status: 500, message: UNKNOWN_ERROR };
});
};

View File

@@ -0,0 +1,26 @@
import { JWT_TOKEN, JWT_REFRESH_TOKEN } from "../../constants";
import { initialState } from "./initialState";
export const reducers = {
logoutUser(state) {
Object.assign(state, initialState);
state.token = null;
state.refreshToken = null;
localStorage.removeItem(JWT_TOKEN);
localStorage.removeItem(JWT_REFRESH_TOKEN);
},
clearError(state) {
state.error = null;
},
clearDeleteStatus(state) {
state.deleteSuccess = false;
state.deletedCount = 0;
},
// addLoadedFiles(state, action) {
// state.loadedFiles = [...state.loadedFiles, ...action.payload];
// },
};

View File

@@ -0,0 +1,33 @@
import { JWT_TOKEN, JWT_REFRESH_TOKEN } from "../../constants";
/**
* Helper to update token in state and localStorage
*/
export const updateToken = (state, token) => {
state.token = token || null;
if (token) {
localStorage.setItem(JWT_TOKEN, token);
} else {
localStorage.removeItem(JWT_TOKEN);
}
};
/**
* Helper to update refresh token in state and localStorage
*/
export const updateRefreshToken = (state, refreshToken) => {
state.refreshToken = refreshToken || null;
if (refreshToken) {
localStorage.setItem(JWT_REFRESH_TOKEN, refreshToken);
} else {
localStorage.removeItem(JWT_REFRESH_TOKEN);
}
};
/**
* Helper to update both tokens
*/
export const updateTokens = (state, token, refreshToken) => {
updateToken(state, token);
updateRefreshToken(state, refreshToken);
};

View File

@@ -0,0 +1,24 @@
import { createSlice } from "@reduxjs/toolkit";
import { initialState } from "./initialState";
import { reducers } from "./reducers";
import { buildAuthExtraReducers } from "./authExtraReducers";
import { buildProfileExtraReducers } from "./profileExtraReducers";
import { buildDeleteExtraReducers } from "./deleteExtraReducers";
const userDetailsSlice = createSlice({
name: "userDetails",
initialState,
reducers,
extraReducers: (builder) => {
buildAuthExtraReducers(builder);
buildProfileExtraReducers(builder);
buildDeleteExtraReducers(builder);
},
});
//export const { logoutUser, clearError, clearDeleteStatus, addLoadedFiles } =
export const { logoutUser, clearError, clearDeleteStatus } =
userDetailsSlice.actions;
export default userDetailsSlice.reducer;

View File

@@ -0,0 +1,13 @@
import { createSlice } from "@reduxjs/toolkit";
import { initialState } from "./initialState";
import { reducers } from "./reducers";
const ragSlice = createSlice({
name: "ragConfig",
initialState,
reducers,
});
export const { switchSearchMode, setTopP } = ragSlice.actions;
export default ragSlice.reducer;

View File

@@ -0,0 +1,9 @@
import {
IS_SEARCH_RESULT_ONLY_WITH_CONTEXT_DEFAULT,
TOP_P_DEFAULT_VALUE,
} from "../../constants";
export const initialState = {
isUseOnlyContextSearch: IS_SEARCH_RESULT_ONLY_WITH_CONTEXT_DEFAULT,
topP: TOP_P_DEFAULT_VALUE,
};

View File

@@ -0,0 +1,9 @@
export const reducers = {
switchSearchMode(state) {
state.isUseOnlyContextSearch = !state.isUseOnlyContextSearch;
},
setTopP(state, action) {
state.topP = action.payload;
},
};

View File

@@ -0,0 +1,19 @@
let currentAbortController = null;
export const getAbortController = () => currentAbortController;
export const createAbortController = () => {
currentAbortController = new AbortController();
return currentAbortController;
};
export const abortAndClear = () => {
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
};
export const clearAbortController = () => {
currentAbortController = null;
};

View File

@@ -0,0 +1,48 @@
import { createSlice } from "@reduxjs/toolkit";
import { initialState } from "./initialState";
import { reducers } from "./reducers";
import { createUploadThunks } from "./thunks";
import {
validateFilesQuota as createValidateFilesQuota,
canUploadFiles,
getRemainingSlots,
} from "./selectors";
const uploadFilesSlice = createSlice({
name: "uploadFiles",
initialState,
reducers,
});
export const {
resetUploadState,
setAreFilesValid,
setQuotaError,
clearQuotaError,
uploadStarted,
progressUpdated,
uploadCompleted,
uploadFailed,
uploadCancelled,
} = uploadFilesSlice.actions;
const { cancelUpload, uploadFilesWithProgress } = createUploadThunks(
uploadFilesSlice.actions
);
const validateFilesQuota = (files) =>
createValidateFilesQuota(files, {
setQuotaError,
setAreFilesValid,
clearQuotaError,
});
export {
cancelUpload,
uploadFilesWithProgress,
validateFilesQuota,
canUploadFiles,
getRemainingSlots,
};
export default uploadFilesSlice.reducer;

View File

@@ -0,0 +1,18 @@
export const initialState = {
uploading: false,
error: {
status: null,
message: "",
},
success: false,
areFilesValid: true,
quotaError: null,
uploadedFiles: [],
progress: {
percent: 0,
processedFiles: 0,
totalFiles: 0,
currentFile: "",
status: "",
},
};

View File

@@ -0,0 +1,62 @@
import { initialState } from "./initialState";
import { abortAndClear } from "./abortController";
import { DEFAULT_ERROR_STATUS, UNKNOWN_ERROR } from "../../constants";
export const reducers = {
resetUploadState() {
abortAndClear();
return initialState;
},
setAreFilesValid(state, action) {
state.areFilesValid = action.payload;
},
setQuotaError(state, action) {
state.quotaError = action.payload;
},
clearQuotaError(state) {
state.quotaError = null;
},
uploadStarted(state, action) {
state.uploading = true;
state.error = { status: null, message: "" };
state.success = false;
state.quotaError = null;
state.progress = {
percent: 0,
processedFiles: 0,
totalFiles: action.payload.totalFiles,
currentFile: "",
status: "starting",
};
},
progressUpdated(state, action) {
state.progress = action.payload;
},
uploadCompleted(state, action) {
state.uploading = false;
state.success = true;
state.uploadedFiles = action.payload.fileNames;
},
uploadFailed(state, action) {
state.uploading = false;
state.success = false;
state.error = {
status: action.payload.status ?? DEFAULT_ERROR_STATUS,
message: action.payload.message ?? UNKNOWN_ERROR,
};
},
uploadCancelled(state) {
state.uploading = false;
state.success = false;
state.error = { status: null, message: "" };
state.progress = initialState.progress;
},
};

View File

@@ -0,0 +1,74 @@
import { MAX_FILES_TO_UPLOAD } from "../../constants";
const getQuotaInfo = (state) => {
const loadedFiles = state.userDetails?.loadedFiles || [];
const maxFilesToLoad = state.userDetails?.maxFilesToLoad || 0;
const loadedCount = loadedFiles.length;
const remainingSlots = Math.max(0, maxFilesToLoad - loadedCount);
return { loadedFiles, maxFilesToLoad, loadedCount, remainingSlots };
};
export const validateFilesQuota =
(files, { setQuotaError, setAreFilesValid, clearQuotaError }) =>
(dispatch, getState) => {
const state = getState();
const { maxFilesToLoad, loadedCount, remainingSlots } = getQuotaInfo(state);
const filesCount = files.length;
if (filesCount > MAX_FILES_TO_UPLOAD) {
const errorMsg = `You can upload maximum ${MAX_FILES_TO_UPLOAD} files at once`;
dispatch(setQuotaError(errorMsg));
dispatch(setAreFilesValid(false));
return { valid: false, error: errorMsg };
}
if (filesCount > remainingSlots) {
const errorMsg =
remainingSlots === 0
? `You have reached the maximum number of files (${maxFilesToLoad})`
: `You can only upload ${remainingSlots} more file(s). Already loaded: ${loadedCount}/${maxFilesToLoad}`;
dispatch(setQuotaError(errorMsg));
dispatch(setAreFilesValid(false));
return { valid: false, error: errorMsg };
}
dispatch(clearQuotaError());
dispatch(setAreFilesValid(true));
return { valid: true, error: null };
};
export const canUploadFiles = () => (dispatch, getState) => {
const state = getState();
const { remainingSlots } = getQuotaInfo(state);
return remainingSlots > 0;
};
export const getRemainingSlots = () => (dispatch, getState) => {
const state = getState();
const { remainingSlots } = getQuotaInfo(state);
return remainingSlots;
};
export const validateQuotaBeforeUpload = (state, filesCount) => {
const { maxFilesToLoad, loadedCount, remainingSlots } = getQuotaInfo(state);
if (filesCount > MAX_FILES_TO_UPLOAD) {
return {
status: 400,
message: `You can upload maximum ${MAX_FILES_TO_UPLOAD} files at once`,
};
}
if (filesCount > remainingSlots) {
return {
status: 400,
message:
remainingSlots === 0
? `You have reached the maximum number of files (${maxFilesToLoad})`
: `You can only upload ${remainingSlots} more file(s). Already loaded: ${loadedCount}/${maxFilesToLoad}`,
};
}
return null;
};

View File

@@ -0,0 +1,190 @@
import fetchUserProfile from "../../fetch-async/fetchUserProfile";
import {
BASE_URL,
METHOD_POST_QUERY,
PREFIX_DOCUMENT,
PREFIX_UPLOAD_STREAM,
FORM_DATA_FILES,
DEFAULT_ERROR_STATUS,
PROGRESS_STATUS_COMPLETED,
PROGRESS_STATUS_ERROR,
ERROR_NO_RESPONSE,
} from "../../constants";
import {
createAbortController,
abortAndClear,
clearAbortController,
} from "./abortController";
import { validateQuotaBeforeUpload } from "./selectors";
export const createUploadThunks = (actions) => {
const {
uploadStarted,
progressUpdated,
uploadCompleted,
uploadFailed,
uploadCancelled,
} = actions;
const cancelUpload = () => (dispatch) => {
abortAndClear();
dispatch(uploadCancelled());
};
const uploadFilesWithProgress = (files) => async (dispatch, getState) => {
const filesArray = Array.from(files);
const fileNames = filesArray.map((f) => f.name);
const state = getState();
const quotaError = validateQuotaBeforeUpload(state, filesArray.length);
if (quotaError) {
dispatch(uploadFailed(quotaError));
return;
}
const abortController = createAbortController();
const signal = abortController.signal;
dispatch(uploadStarted({ totalFiles: filesArray.length }));
const formData = new FormData();
filesArray.forEach((file) => {
formData.append(FORM_DATA_FILES, file);
});
const token = state.userDetails?.token;
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
try {
const response = await fetch(
`${BASE_URL}${PREFIX_DOCUMENT}${PREFIX_UPLOAD_STREAM}`,
{
method: METHOD_POST_QUERY,
headers,
body: formData,
signal,
}
);
if (!response.ok) {
dispatch(
uploadFailed({
status: response.status,
message: `Server error: ${response.statusText}`,
})
);
return;
}
await processSSEStream(response, signal, dispatch, fileNames, {
progressUpdated,
uploadCompleted,
uploadFailed,
});
const currentState = getState();
if (currentState.uploadFiles.uploading) {
dispatch(uploadCompleted({ fileNames }));
dispatch(fetchUserProfile());
}
} catch (err) {
if (err.name === "AbortError") {
console.log("Upload cancelled by user");
return;
}
console.error("Upload stream error:", err);
dispatch(
uploadFailed({
status: DEFAULT_ERROR_STATUS,
message: `${ERROR_NO_RESPONSE}: ${err.message}`,
})
);
} finally {
clearAbortController();
}
};
return {
cancelUpload,
uploadFilesWithProgress,
};
};
async function processSSEStream(
response,
signal,
dispatch,
fileNames,
actions
) {
const { progressUpdated, uploadCompleted, uploadFailed } = actions;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
if (signal.aborted) {
reader.cancel();
return;
}
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
processSSELine(line, dispatch, fileNames, {
progressUpdated,
uploadCompleted,
uploadFailed,
});
}
}
} finally {
reader.releaseLock();
}
}
function processSSELine(line, dispatch, fileNames, actions) {
const { progressUpdated, uploadCompleted, uploadFailed } = actions;
if (!line.startsWith("data:")) {
return;
}
const jsonStr = line.slice(5).trim();
if (!jsonStr || !jsonStr.startsWith("{")) {
return;
}
try {
const progressData = JSON.parse(jsonStr);
dispatch(progressUpdated(progressData));
if (progressData.status === PROGRESS_STATUS_COMPLETED) {
dispatch(uploadCompleted({ fileNames }));
dispatch(fetchUserProfile());
} else if (progressData.status === PROGRESS_STATUS_ERROR) {
dispatch(
uploadFailed({
status: DEFAULT_ERROR_STATUS,
message: `Error processing file: ${progressData.currentFile}`,
})
);
}
} catch (parseError) {
console.error("Failed to parse SSE data:", parseError);
}
}

View File

@@ -0,0 +1,333 @@
import { createSlice } from "@reduxjs/toolkit";
//import { addLoadedFiles } from "./slices/details-slice";
import fetchUserProfile from "./fetch-async/fetchUserProfile";
import {
BASE_URL,
METHOD_POST_QUERY,
PREFIX_DOCUMENT,
PREFIX_UPLOAD_STREAM,
FORM_DATA_FILES,
DEFAULT_ERROR_STATUS,
PROGRESS_STATUS_COMPLETED,
PROGRESS_STATUS_ERROR,
ERROR_NO_RESPONSE,
UNKNOWN_ERROR,
MAX_FILES_TO_UPLOAD,
} from "./constants";
const initialState = {
uploading: false,
error: {
status: null,
message: "",
},
success: false,
areFilesValid: true,
quotaError: null,
uploadedFiles: [],
progress: {
percent: 0,
processedFiles: 0,
totalFiles: 0,
currentFile: "",
status: "",
},
};
// Store AbortController outside Redux state
let currentAbortController = null;
const uploadFilesSlice = createSlice({
name: "uploadFiles",
initialState,
reducers: {
resetUploadState() {
// Abort any ongoing upload when resetting
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
return initialState;
},
setAreFilesValid(state, action) {
state.areFilesValid = action.payload;
},
setQuotaError(state, action) {
state.quotaError = action.payload;
},
clearQuotaError(state) {
state.quotaError = null;
},
uploadStarted(state, action) {
state.uploading = true;
state.error = { status: null, message: "" };
state.success = false;
state.quotaError = null;
state.progress = {
percent: 0,
processedFiles: 0,
totalFiles: action.payload.totalFiles,
currentFile: "",
status: "starting",
};
},
progressUpdated(state, action) {
state.progress = action.payload;
},
uploadCompleted(state, action) {
state.uploading = false;
state.success = true;
state.uploadedFiles = action.payload.fileNames;
},
uploadFailed(state, action) {
state.uploading = false;
state.success = false;
state.error = {
status: action.payload.status ?? DEFAULT_ERROR_STATUS,
message: action.payload.message ?? UNKNOWN_ERROR,
};
},
uploadCancelled(state) {
state.uploading = false;
state.success = false;
state.error = { status: null, message: "" };
state.progress = initialState.progress;
},
},
});
export const {
resetUploadState,
setAreFilesValid,
setQuotaError,
clearQuotaError,
uploadStarted,
progressUpdated,
uploadCompleted,
uploadFailed,
uploadCancelled,
} = uploadFilesSlice.actions;
export const cancelUpload = () => (dispatch) => {
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
dispatch(uploadCancelled());
};
/**
* Validate files against quota limits
* Returns { valid: boolean, error: string | null }
*/
export const validateFilesQuota = (files) => (dispatch, getState) => {
const state = getState();
const loadedFiles = state.userDetails?.loadedFiles || [];
const maxFilesToLoad = state.userDetails?.maxFilesToLoad || 0;
const loadedCount = loadedFiles.length;
const remainingSlots = Math.max(0, maxFilesToLoad - loadedCount);
const filesCount = files.length;
// Check max files per upload
if (filesCount > MAX_FILES_TO_UPLOAD) {
const errorMsg = `You can upload maximum ${MAX_FILES_TO_UPLOAD} files at once`;
dispatch(setQuotaError(errorMsg));
dispatch(setAreFilesValid(false));
return { valid: false, error: errorMsg };
}
// Check remaining slots
if (filesCount > remainingSlots) {
const errorMsg =
remainingSlots === 0
? `You have reached the maximum number of files (${maxFilesToLoad})`
: `You can only upload ${remainingSlots} more file(s). Already loaded: ${loadedCount}/${maxFilesToLoad}`;
dispatch(setQuotaError(errorMsg));
dispatch(setAreFilesValid(false));
return { valid: false, error: errorMsg };
}
dispatch(clearQuotaError());
dispatch(setAreFilesValid(true));
return { valid: true, error: null };
};
/**
* Check if user can upload files (has remaining slots)
*/
export const canUploadFiles = () => (dispatch, getState) => {
const state = getState();
const loadedFiles = state.userDetails?.loadedFiles || [];
const maxFilesToLoad = state.userDetails?.maxFilesToLoad || 0;
const remainingSlots = Math.max(0, maxFilesToLoad - loadedFiles.length);
return remainingSlots > 0;
};
/**
* Get remaining upload slots
*/
export const getRemainingSlots = () => (dispatch, getState) => {
const state = getState();
const loadedFiles = state.userDetails?.loadedFiles || [];
const maxFilesToLoad = state.userDetails?.maxFilesToLoad || 0;
return Math.max(0, maxFilesToLoad - loadedFiles.length);
};
/**
* Thunk for uploading files with SSE progress streaming.
*/
export const uploadFilesWithProgress =
(files) => async (dispatch, getState) => {
const filesArray = Array.from(files);
const fileNames = filesArray.map((f) => f.name);
// Validate quota before upload
const state = getState();
const loadedFiles = state.userDetails?.loadedFiles || [];
const maxFilesToLoad = state.userDetails?.maxFilesToLoad || 0;
const loadedCount = loadedFiles.length;
const remainingSlots = Math.max(0, maxFilesToLoad - loadedCount);
// Check max files per upload
if (filesArray.length > MAX_FILES_TO_UPLOAD) {
dispatch(
uploadFailed({
status: 400,
message: `You can upload maximum ${MAX_FILES_TO_UPLOAD} files at once`,
})
);
return;
}
// Check remaining slots
if (filesArray.length > remainingSlots) {
dispatch(
uploadFailed({
status: 400,
message:
remainingSlots === 0
? `You have reached the maximum number of files (${maxFilesToLoad})`
: `You can only upload ${remainingSlots} more file(s). Already loaded: ${loadedCount}/${maxFilesToLoad}`,
})
);
return;
}
// Create new AbortController for this upload
currentAbortController = new AbortController();
const signal = currentAbortController.signal;
dispatch(uploadStarted({ totalFiles: filesArray.length }));
const formData = new FormData();
filesArray.forEach((file) => {
formData.append(FORM_DATA_FILES, file);
});
const token = state.userDetails?.token;
const headers = {};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
try {
const response = await fetch(
`${BASE_URL}${PREFIX_DOCUMENT}${PREFIX_UPLOAD_STREAM}`,
{
method: METHOD_POST_QUERY,
headers,
body: formData,
signal,
}
);
if (!response.ok) {
dispatch(
uploadFailed({
status: response.status,
message: `Server error: ${response.statusText}`,
})
);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
// Check if aborted before reading
if (signal.aborted) {
reader.cancel();
return;
}
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data:")) {
const jsonStr = line.slice(5).trim();
if (jsonStr && jsonStr.startsWith("{")) {
try {
const progressData = JSON.parse(jsonStr);
dispatch(progressUpdated(progressData));
if (progressData.status === PROGRESS_STATUS_COMPLETED) {
dispatch(uploadCompleted({ fileNames }));
dispatch(fetchUserProfile());
//dispatch(addLoadedFiles(fileNames));
} else if (progressData.status === PROGRESS_STATUS_ERROR) {
dispatch(
uploadFailed({
status: DEFAULT_ERROR_STATUS,
message: `Error processing file: ${progressData.currentFile}`,
})
);
}
} catch (parseError) {
console.error("Failed to parse SSE data:", parseError);
}
}
}
}
}
// Ensure completion if stream ended without explicit completed status
const currentState = getState();
if (currentState.uploadFiles.uploading) {
dispatch(uploadCompleted({ fileNames }));
dispatch(addLoadedFiles(fileNames));
}
} catch (err) {
// Don't dispatch error if it was an intentional abort
if (err.name === "AbortError") {
console.log("Upload cancelled by user");
return;
}
console.error("Upload stream error:", err);
dispatch(
uploadFailed({
status: DEFAULT_ERROR_STATUS,
message: `${ERROR_NO_RESPONSE}: ${err.message}`,
})
);
} finally {
currentAbortController = null;
}
};
//export default uploadFilesSlice.reducer;

View File

@@ -0,0 +1,199 @@
import { createSlice } from "@reduxjs/toolkit";
import fetchLoginUser from "./fetch-async/fetchLoginUser";
import fetchRegisterUser from "./fetch-async/fetchRegisterUser";
import fetchRefreshToken from "./fetch-async/fetchRefreshToken";
import fetchUserProfile from "./fetch-async/fetchUserProfile";
import fetchDeleteUploaded from "./fetch-async/fetchDeleteUploaded";
import {
USER_NAME_UNDEFINED,
USER_EMAIL_UNDEFINED,
JWT_TOKEN,
JWT_REFRESH_TOKEN,
TYPE_WINDOW_UNDEFINED,
TOKEN_UNDEFINED,
UNKNOWN_ERROR,
} from "./constants";
const getInitialToken = () => {
if (typeof window === TYPE_WINDOW_UNDEFINED) return null;
const token = localStorage.getItem(JWT_TOKEN);
if (!token || token === TOKEN_UNDEFINED) return null;
return token;
};
const initialState = {
userId: null,
userName: USER_NAME_UNDEFINED,
userEmail: USER_EMAIL_UNDEFINED,
loadedFiles: [],
maxFilesToLoad: 0,
token: getInitialToken(),
refreshToken: localStorage.getItem(JWT_REFRESH_TOKEN),
loading: false,
deleting: false,
deleteSuccess: false,
deletedCount: 0,
error: null,
};
const userDetailsSlice = createSlice({
name: "userDetails",
initialState,
reducers: {
logoutUser(state) {
state.userId = null;
state.userName = USER_NAME_UNDEFINED;
state.userEmail = USER_EMAIL_UNDEFINED;
state.loadedFiles = [];
state.maxFilesToLoad = 0;
state.token = null;
state.refreshToken = null;
state.loading = false;
state.deleting = false;
state.deleteSuccess = false;
state.deletedCount = 0;
state.error = null;
localStorage.removeItem(JWT_TOKEN);
localStorage.removeItem(JWT_REFRESH_TOKEN);
},
clearError(state) {
state.error = null;
},
clearDeleteStatus(state) {
state.deleteSuccess = false;
state.deletedCount = 0;
},
addLoadedFiles(state, action) {
state.loadedFiles = [...state.loadedFiles, ...action.payload];
},
},
extraReducers: (builder) => {
builder.addCase(fetchRegisterUser.pending, (state) => {
state.loading = true;
state.error = null;
});
builder.addCase(fetchRegisterUser.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
state.userName = action.payload.payload.username;
state.userEmail = action.payload.payload.email;
const token = action.payload.payload.token;
state.token = token || null;
if (token) {
localStorage.setItem(JWT_TOKEN, token);
} else {
localStorage.removeItem(JWT_TOKEN);
}
const refreshToken = action.payload.payload.refreshToken;
state.refreshToken = refreshToken || null;
if (refreshToken) {
localStorage.setItem(JWT_REFRESH_TOKEN, refreshToken);
} else {
localStorage.removeItem(JWT_REFRESH_TOKEN);
}
});
builder.addCase(fetchRegisterUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload?.status ?? 500;
});
builder.addCase(fetchLoginUser.pending, (state) => {
state.loading = true;
state.error = null;
});
builder.addCase(fetchLoginUser.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
state.userName = action.payload.payload.username;
state.userEmail = action.payload.payload.email;
const token = action.payload.payload.token;
state.token = token || null;
if (token) {
localStorage.setItem(JWT_TOKEN, token);
} else {
localStorage.removeItem(JWT_TOKEN);
}
const refreshToken = action.payload.payload.refreshToken;
state.refreshToken = refreshToken || null;
if (refreshToken) {
localStorage.setItem(JWT_REFRESH_TOKEN, refreshToken);
} else {
localStorage.removeItem(JWT_REFRESH_TOKEN);
}
});
builder.addCase(fetchLoginUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? { status: 500, message: "Unknown error" };
});
builder.addCase(fetchRefreshToken.fulfilled, (state, action) => {
const token = action.payload?.payload?.token;
const refreshToken = action.payload?.payload?.refreshToken;
state.token = token || null;
state.refreshToken = refreshToken || null;
if (token) localStorage.setItem(JWT_TOKEN, token);
else localStorage.removeItem(JWT_TOKEN);
if (refreshToken) localStorage.setItem(JWT_REFRESH_TOKEN, refreshToken);
else localStorage.removeItem(JWT_REFRESH_TOKEN);
});
builder.addCase(fetchRefreshToken.rejected, (state, action) => {
state.error = action.payload ?? { status: 500, message: UNKNOWN_ERROR };
});
builder.addCase(fetchUserProfile.pending, (state) => {
state.loading = true;
state.error = null;
});
builder.addCase(fetchUserProfile.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
const p = action.payload?.payload;
if (p?.id) state.userId = p.id;
if (p?.username) state.userName = p.username;
if (p?.email) state.userEmail = p.email;
if (p?.loadedFiles) state.loadedFiles = p.loadedFiles;
if (p?.maxLoadedFiles != null) state.maxFilesToLoad = p.maxLoadedFiles;
});
builder.addCase(fetchUserProfile.rejected, (state, action) => {
state.loading = false;
state.error = action.payload ?? { status: 500, message: "Unknown error" };
});
// Delete uploaded files
builder.addCase(fetchDeleteUploaded.pending, (state) => {
state.deleting = true;
state.deleteSuccess = false;
state.deletedCount = 0;
state.error = null;
});
builder.addCase(fetchDeleteUploaded.fulfilled, (state, action) => {
state.deleting = false;
state.deleteSuccess = true;
state.deletedCount = action.payload?.payload ?? 0;
state.loadedFiles = [];
state.error = null;
});
builder.addCase(fetchDeleteUploaded.rejected, (state, action) => {
state.deleting = false;
state.deleteSuccess = false;
state.error = action.payload ?? { status: 500, message: "Unknown error" };
});
},
});
//export const { logoutUser, clearError, clearDeleteStatus, addLoadedFiles } = userDetailsSlice.actions;
//export default userDetailsSlice.reducer;

1
rag-view/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

15
rag-view/src/main.jsx Normal file
View File

@@ -0,0 +1,15 @@
import React from "react";
import { Provider } from "react-redux";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
import store from "./store";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);

View File

@@ -0,0 +1,300 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import {
logoutUser,
clearDeleteStatus,
} from "../features/slices/details-slice";
import { uploadFilesWithProgress } from "../features/slices/upload-slice";
import fetchDeleteUploaded from "../features/fetch-async/fetchDeleteUploaded";
import fetchLoginUser from "../features/fetch-async/fetchLoginUser";
import {
TOKEN_UNDEFINED,
GUEST_EMAIL,
GUEST_PASSWORD,
} from "../features/constants";
const GUEST_USERNAME = "Guest";
const DEFAULT_CONTEXT_FILE = "/guest-example.txt";
const EXAMPLE_QUESTIONS_FILE = "/context-questions.txt";
function HomePage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const [view, setView] = useState(null);
const [fileContent, setFileContent] = useState("");
const [uploadingDefault, setUploadingDefault] = useState(false);
const token = useSelector((state) => state.userDetails.token);
const userId = useSelector((state) => state.userDetails.userId);
const userName = useSelector((state) => state.userDetails.userName);
const userEmail = useSelector((state) => state.userDetails.userEmail);
const loadedFiles = useSelector((state) => state.userDetails.loadedFiles);
const loading = useSelector((state) => state.userDetails.loading);
const deleting = useSelector((state) => state.userDetails.deleting);
const deleteSuccess = useSelector((state) => state.userDetails.deleteSuccess);
const deletedCount = useSelector((state) => state.userDetails.deletedCount);
const auth =
typeof token === "string" &&
token !== TOKEN_UNDEFINED &&
token.trim() !== "";
const isGuest =
userEmail === GUEST_EMAIL && userName === GUEST_USERNAME;
const hasDefaultFile = loadedFiles.some(
(f) => f.fileName === "guest-example.txt",
);
const showUploadDefault = isGuest && !hasDefaultFile;
const showViewExamples = isGuest && hasDefaultFile;
const handleGuestLogin = () => {
dispatch(fetchLoginUser({ email: GUEST_EMAIL, password: GUEST_PASSWORD }));
};
const handleLogout = () => {
dispatch(logoutUser());
};
const handleDeleteUploaded = () => {
if (window.confirm("Are you sure you want to delete all uploaded files?")) {
dispatch(fetchDeleteUploaded());
}
};
const handleClearDeleteStatus = () => {
dispatch(clearDeleteStatus());
};
const handleUploadDefaultFile = async () => {
setUploadingDefault(true);
try {
const res = await fetch(DEFAULT_CONTEXT_FILE);
const text = await res.text();
const file = new File([text], "guest-example.txt", {
type: "text/plain",
});
dispatch(uploadFilesWithProgress([file]));
navigate("/upload");
} finally {
setUploadingDefault(false);
}
};
const handleViewFile = async (filePath, viewName) => {
const res = await fetch(filePath);
const text = await res.text();
setFileContent(text);
setView(viewName);
};
// File viewer screen
if (view) {
const title =
view === "context" ? "Example Context File" : "Example Questions";
return (
<div className="flex items-center justify-center h-screen bg-slate-950 p-4 overflow-hidden">
<div className="w-full max-w-md bg-slate-900/90 rounded-2xl border border-slate-700/60 shadow-xl p-6 flex flex-col max-h-[90vh]">
<h2 className="text-lg font-semibold text-white mb-3">{title}</h2>
<pre className="flex-1 overflow-y-auto text-xs text-slate-300 bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 whitespace-pre-wrap">
{fileContent}
</pre>
<button
onClick={() => setView(null)}
className="mt-4 w-full py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-sm font-medium text-white transition-colors"
>
Back
</button>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center h-screen bg-slate-950 p-4 overflow-hidden">
<div className="w-full max-w-md bg-slate-900/90 rounded-2xl border border-slate-700/60 shadow-xl p-6">
{/* Header */}
<div className="text-center mb-4">
<h1 className="text-xl font-semibold text-white">RAG Viewer</h1>
<p className="text-xs text-slate-400 mt-1">
Choose an option to start working with your text files and RAG
queries.
</p>
</div>
{!auth ? (
<div className="space-y-3">
<button
onClick={() => navigate("/register")}
className="w-full py-2.5 rounded-xl bg-indigo-500 hover:bg-indigo-400 active:bg-indigo-600 text-sm font-medium text-white shadow-md shadow-indigo-500/25 transition-colors"
>
Register
</button>
<button
onClick={() => navigate("/login")}
className="w-full py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 active:bg-slate-600 text-sm font-medium text-white transition-colors"
>
Login
</button>
<button
onClick={handleGuestLogin}
className="w-full py-2.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 active:bg-emerald-600 text-sm font-medium text-white shadow-md shadow-emerald-500/25 transition-colors"
>
Login as guest
</button>
</div>
) : (
<div className="space-y-4">
{/* User Info Section */}
<div className="bg-slate-800/60 rounded-xl p-3 border border-slate-700/40">
<h2 className="text-sm font-semibold text-slate-200 mb-2">
User Profile
</h2>
{loading ? (
<p className="text-slate-400 text-xs">Loading...</p>
) : (
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-slate-500 block">ID</span>
<span className="text-slate-200">{userId ?? "—"}</span>
</div>
<div>
<span className="text-slate-500 block">Username</span>
<span className="text-slate-200">{userName}</span>
</div>
<div>
<span className="text-slate-500 block">Email</span>
<span className="text-slate-200 truncate block">
{userEmail}
</span>
</div>
</div>
)}
</div>
{/* Delete Success Message */}
{deleteSuccess && (
<div className="bg-emerald-500/15 border border-emerald-500/40 rounded-xl p-2.5">
<div className="flex items-center justify-between">
<p className="text-emerald-400 text-xs">
Successfully deleted {deletedCount} file(s)
</p>
<button
onClick={handleClearDeleteStatus}
className="text-emerald-400 hover:text-emerald-300 text-xs ml-2"
>
</button>
</div>
</div>
)}
{/* Loaded Files Section */}
<div className="bg-slate-800/60 rounded-xl p-3 border border-slate-700/40">
<h2 className="text-sm font-semibold text-slate-200 mb-2">
Loaded Documents ({loadedFiles.length})
</h2>
{loading ? (
<p className="text-slate-400 text-xs">Loading...</p>
) : loadedFiles.length === 0 ? (
<p className="text-slate-400 text-xs">
No documents uploaded yet
</p>
) : (
<ul className="space-y-1.5 max-h-24 overflow-y-auto">
{loadedFiles.map((file) => (
<li
key={file.id}
className="flex items-center gap-2 text-xs bg-slate-700/40 rounded-lg px-2.5 py-1.5"
>
<svg
className="w-3.5 h-3.5 text-indigo-400 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<span className="text-slate-200 truncate flex-1">
{file.fileName}
</span>
<span className="text-slate-500 text-[10px]">
#{file.id}
</span>
</li>
))}
</ul>
)}
</div>
{/* Action Buttons */}
<div className="grid grid-cols-2 gap-2">
<button
onClick={() => navigate("/upload")}
className="py-2.5 rounded-xl bg-indigo-500 hover:bg-indigo-400 active:bg-indigo-600 text-xs font-medium text-white shadow-md shadow-indigo-500/25 transition-colors"
>
Upload TXT
</button>
{showUploadDefault && (
<button
onClick={handleUploadDefaultFile}
disabled={uploadingDefault}
className="py-2.5 rounded-xl bg-teal-500 hover:bg-teal-400 active:bg-teal-600 text-xs font-medium text-white shadow-md shadow-teal-500/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{uploadingDefault ? "Uploading..." : "Upload Default Context"}
</button>
)}
<button
onClick={() => navigate("/rag")}
className="py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 active:bg-slate-800 text-xs font-medium text-white transition-colors"
>
New RAG Query
</button>
{showViewExamples && (
<>
<button
onClick={() =>
handleViewFile(DEFAULT_CONTEXT_FILE, "context")
}
className="py-2.5 rounded-xl bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 text-xs font-medium text-white transition-colors"
>
View Example Context
</button>
<button
onClick={() =>
handleViewFile(EXAMPLE_QUESTIONS_FILE, "questions")
}
className="py-2.5 rounded-xl bg-violet-600 hover:bg-violet-500 active:bg-violet-700 text-xs font-medium text-white transition-colors"
>
View Example Questions
</button>
</>
)}
<button
onClick={handleDeleteUploaded}
disabled={deleting || loadedFiles.length === 0}
className="py-2.5 rounded-xl bg-orange-500 hover:bg-orange-400 active:bg-orange-600 text-xs font-medium text-white shadow-md shadow-orange-500/25 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{deleting ? "Deleting..." : "Delete Files"}
</button>
<button
onClick={handleLogout}
className="py-2.5 rounded-xl bg-red-500 hover:bg-red-400 active:bg-red-600 text-xs font-medium text-white shadow-md shadow-red-500/25 transition-colors"
>
Logout
</button>
</div>
</div>
)}
</div>
</div>
);
}
export default HomePage;

View File

@@ -0,0 +1,136 @@
import { useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import { clearError } from "../features/slices/details-slice";
import fetchLoginUser from "../features/fetch-async/fetchLoginUser";
function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const dispatch = useDispatch();
const navigate = useNavigate();
const { loading, error, token } = useSelector((state) => state.userDetails);
// Redirect if already logged in
useEffect(() => {
if (token) {
navigate("/");
}
}, [token, navigate]);
// Clear error on unmount
useEffect(() => {
return () => {
dispatch(clearError());
};
}, [dispatch]);
const handleSubmit = async (e) => {
e.preventDefault();
const result = await dispatch(fetchLoginUser({ email, password }));
if (fetchLoginUser.fulfilled.match(result)) {
navigate("/");
}
};
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-md bg-slate-900/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-slate-700/60 p-8">
<h1 className="text-2xl font-semibold text-slate-50 text-center mb-2">
Login
</h1>
<p className="text-sm text-slate-400 text-center mb-6">
Enter your email and password, then press the button to continue.
</p>
{error && (
<div className="mb-4 p-3 rounded-xl bg-red-500/20 border border-red-500/50 text-red-300 text-sm text-center">
{error.message || `Error: ${error.status}`}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-200">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Your email"
className="w-full rounded-xl bg-slate-800/80 border border-slate-600 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/60 focus:outline-none px-4 py-2.5 text-slate-100 placeholder:text-slate-500 text-sm"
required
disabled={loading}
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-200">
Password
</label>
<input
type="password"
value={password}
minLength={8}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
className="w-full rounded-xl bg-slate-800/80 border border-slate-600 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/60 focus:outline-none px-4 py-2.5 text-slate-100 placeholder:text-slate-500 text-sm"
required
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full mt-2 inline-flex items-center justify-center rounded-xl bg-indigo-500 hover:bg-indigo-400 active:bg-indigo-600 disabled:bg-indigo-500/50 disabled:cursor-not-allowed transition-colors px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-500/30"
>
{loading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Logging in...
</>
) : (
"Login"
)}
</button>
</form>
<p className="mt-6 text-center text-sm text-slate-400">
Don't have an account?{" "}
<a
href="/register"
className="text-indigo-400 hover:text-indigo-300 transition-colors"
>
Register
</a>
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,189 @@
import { useRef, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import fetchCreateNewChat from "../../../features/fetch-async/fetchCreateNewChat";
import fetchAddNewUserEntry from "../../../features/fetch-async/fetchAddNewUserEntry";
import fetchGetChat from "../../../features/fetch-async/fetchGetChat";
import { generateTitleFromMessage } from "../utils/titleUtils";
import { MESSAGE_ROLE } from "../../../features/constants";
const ChatArea = () => {
const dispatch = useDispatch();
const [inputValue, setInputValue] = useState("");
const { loadedFiles } = useSelector((state) => state.userDetails);
const activeChatId = useSelector((state) => state.chats.activeChatId);
const messages = useSelector((state) => state.chats.messages);
const isWaitingResponse = useSelector(
(state) => state.chats.isWaitingResponse,
);
const messagesEndRef = useRef(null);
const { isUseOnlyContextSearch, topP } = useSelector(
(state) => state.ragConfig,
);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSendMessage = () => {
const trimmedInput = inputValue.trim();
if (!trimmedInput) return;
if (!activeChatId) {
// No active chat - create a new one, then send the entry
const title = generateTitleFromMessage(trimmedInput);
dispatch(fetchCreateNewChat(title)).then((result) => {
if (fetchCreateNewChat.fulfilled.match(result)) {
const newChatId = result.payload.id;
dispatch(
fetchAddNewUserEntry({
chatId: newChatId,
content: trimmedInput,
onlyContext: isUseOnlyContextSearch,
topP,
}),
).then((entryResult) => {
if (fetchAddNewUserEntry.fulfilled.match(entryResult)) {
dispatch(fetchGetChat(newChatId));
}
});
}
});
} else {
// Active chat exists - send message to it
dispatch(
fetchAddNewUserEntry({
chatId: activeChatId,
content: trimmedInput,
onlyContext: isUseOnlyContextSearch,
topP,
}),
).then((result) => {
if (fetchAddNewUserEntry.fulfilled.match(result)) {
dispatch(fetchGetChat(activeChatId));
}
});
}
setInputValue("");
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
if (!loadedFiles || loadedFiles.length === 0) {
return (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-6 text-center">
<svg
className="w-12 h-12 text-amber-400 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-amber-200 font-medium">Unable to make RAG query</p>
<p className="text-amber-300/70 text-sm mt-1">
Please upload at least one context file first
</p>
</div>
);
}
return (
<div className="bg-slate-800/40 rounded-xl p-8 border border-slate-700/40">
{/* Messages area */}
<div className="min-h-50 max-h-[60vh] overflow-y-auto mb-6 space-y-3">
{!activeChatId || messages.length === 0 ? (
<div className="flex items-center justify-center h-full">
<p className="text-slate-500">
{!activeChatId
? "Start a new conversation..."
: "No messages yet"}
</p>
</div>
) : (
messages.map((msg, index) => {
const isUser = msg.role === MESSAGE_ROLE.USER;
return (
<div
key={index}
className={`flex ${isUser ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[75%] px-4 py-2.5 rounded-xl text-sm whitespace-pre-wrap ${
isUser
? "bg-indigo-600/60 text-white rounded-br-sm"
: "bg-slate-700/60 text-slate-200 rounded-bl-sm"
}`}
>
{msg.content}
</div>
</div>
);
})
)}
{isWaitingResponse && (
<div className="flex justify-start">
<div className="bg-slate-700/60 px-4 py-2.5 rounded-xl rounded-bl-sm">
<div className="flex gap-1.5">
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce [animation-delay:0ms]" />
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce [animation-delay:300ms]" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="flex items-center gap-3">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
activeChatId ? "Type your message..." : "Start a conversation..."
}
className="flex-1 px-4 py-3 rounded-xl bg-slate-900/60 border border-slate-600/50 text-white placeholder-slate-400 text-sm focus:outline-none focus:border-indigo-500 transition-colors"
/>
<button
onClick={handleSendMessage}
disabled={!inputValue.trim()}
className={`p-3 rounded-xl transition-all ${
inputValue.trim()
? "bg-linear-to-br from-orange-500 to-amber-600 hover:from-orange-400 hover:to-amber-500 text-white shadow-lg shadow-orange-500/25"
: "bg-slate-700/50 text-slate-500 cursor-not-allowed"
}`}
aria-label="Send message"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</button>
</div>
</div>
);
};
export default ChatArea;

View File

@@ -0,0 +1,102 @@
import { useSelector, useDispatch } from "react-redux";
import { setActiveChat } from "../../../features/slices/chat-slice";
import { formatDate } from "../utils/formatDate";
import fetchDeleteChat from "../../../features/fetch-async/fetchDeleteChat";
const ChatList = ({ chats }) => {
const dispatch = useDispatch();
const activeChatId = useSelector((state) => state.chats.activeChatId);
const handleChatClick = (chat) => {
dispatch(setActiveChat(chat));
};
const handleDeleteChat = (e, chatId) => {
e.stopPropagation();
dispatch(fetchDeleteChat(chatId));
};
// Sort chats: active chat first, then by date
const sortedChats = [...chats].sort((a, b) => {
if (a.id === activeChatId) return -1;
if (b.id === activeChatId) return 1;
return new Date(b.createdAt) - new Date(a.createdAt);
});
return (
<div className="flex-1 overflow-y-auto px-3">
<div className="flex items-center gap-2 px-2 py-2 text-slate-400">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<span className="text-sm font-medium">Chats</span>
</div>
<div className="space-y-1">
{sortedChats.map((chat) => {
const isActive = chat.id === activeChatId;
return (
<button
key={chat.id}
onClick={() => handleChatClick(chat)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors group flex items-center justify-between gap-2 ${
isActive
? "bg-indigo-600/30 text-indigo-200 border border-indigo-500/40"
: "text-slate-300 hover:bg-slate-800/60 hover:text-white"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{chat.title}</p>
<p
className={`text-xs ${
isActive
? "text-indigo-400/70"
: "text-slate-500 group-hover:text-slate-400"
}`}
>
{formatDate(chat.createdAt)}
</p>
</div>
<button
onClick={(e) => handleDeleteChat(e, chat.id)}
className={`shrink-0 p-1.5 rounded-md transition-colors ${
isActive
? "hover:bg-red-500/20 text-indigo-300 hover:text-red-400"
: "hover:bg-red-500/20 text-slate-400 hover:text-red-400"
}`}
aria-label="Delete chat"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</button>
);
})}
</div>
</div>
);
};
export default ChatList;

View File

@@ -0,0 +1,90 @@
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import fetchCreateNewChat from "../../../features/fetch-async/fetchCreateNewChat";
import { NO_ACTIVE_CHAT_ID } from "../../../features/constants";
const NewChatButton = () => {
const dispatch = useDispatch();
const activeChatId = useSelector((state) => state.chats.activeChatId);
const isDisabled = activeChatId === NO_ACTIVE_CHAT_ID;
const [isCreating, setIsCreating] = useState(false);
const [title, setTitle] = useState("");
const handleCreate = () => {
if (title.trim()) {
// Create new chat with title only (no initial message)
dispatch(fetchCreateNewChat(title.trim()));
setIsCreating(false);
setTitle("");
}
};
const handleCancel = () => {
setIsCreating(false);
setTitle("");
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && title.trim()) handleCreate();
if (e.key === "Escape") handleCancel();
};
if (isCreating) {
return (
<div className="p-3">
<div className="flex items-center gap-2">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleCancel}
placeholder="Title"
autoFocus
className="flex-1 px-3 py-2.5 rounded-lg bg-slate-800 border border-slate-600 text-white placeholder-slate-400 text-sm focus:outline-none focus:border-indigo-500"
/>
{title.trim() && (
<button
onMouseDown={(e) => e.preventDefault()}
onClick={handleCreate}
className="px-3 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors"
>
Ok
</button>
)}
</div>
</div>
);
}
return (
<div className="p-3">
<button
onClick={() => setIsCreating(true)}
disabled={isDisabled}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors ${
isDisabled
? "bg-slate-700/50 text-slate-500 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-500 text-white"
}`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
New chat
</button>
</div>
);
};
export default NewChatButton;

View File

@@ -0,0 +1,127 @@
import { useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import {
switchSearchMode,
setTopP,
} from "../../../features/slices/rag-slice";
import {
TOP_P_FAST_VALUE,
TOP_P_DEFAULT_VALUE,
TOP_P_SLOW_VALUE,
} from "../../../features/constants";
const SettingsPanel = () => {
const dispatch = useDispatch();
const [isOpen, setIsOpen] = useState(false);
const { isUseOnlyContextSearch, topP } = useSelector(
(state) => state.ragConfig,
);
return (
<div className="p-3 pb-4 border-t border-slate-700/50 shrink-0 relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-800/60 hover:text-white transition-colors"
>
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="text-sm font-medium">Settings</span>
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</button>
{isOpen && (
<div className="mt-2 bg-slate-800/80 rounded-lg border border-slate-700/50 p-3 space-y-3">
{/* Search Mode */}
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wider">
Search Mode
</h4>
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="searchMode"
checked={isUseOnlyContextSearch}
onChange={() => dispatch(switchSearchMode())}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Only context</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="searchMode"
checked={!isUseOnlyContextSearch}
onChange={() => dispatch(switchSearchMode())}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Allow external</span>
</label>
</div>
</div>
{/* Top P */}
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wider">
Top P
</h4>
<div className="space-y-1.5">
{[
{ value: TOP_P_FAST_VALUE, label: "Fast" },
{ value: TOP_P_DEFAULT_VALUE, label: "Default" },
{ value: TOP_P_SLOW_VALUE, label: "Large scan" },
].map(({ value, label }) => (
<label
key={value}
className="flex items-center gap-2 cursor-pointer text-xs"
>
<input
type="radio"
name="topP"
checked={topP === value}
onChange={() => dispatch(setTopP(value))}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">{label}</span>
</label>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default SettingsPanel;

View File

@@ -0,0 +1,15 @@
import NewChatButton from "./NewChatButton";
import ChatList from "./ChatList";
import SettingsPanel from "./SettingsPanel";
const Sidebar = ({ chats }) => {
return (
<aside className="w-64 bg-slate-900/80 border-r border-slate-700/50 flex flex-col backdrop-blur-sm">
<NewChatButton />
<ChatList chats={chats} />
<SettingsPanel />
</aside>
);
};
export default Sidebar;

View File

@@ -0,0 +1,48 @@
import { useSelector } from "react-redux";
const UserProfile = () => {
const { userName, userEmail, loading } = useSelector(
(state) => state.userDetails,
);
return (
<div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 mb-6 backdrop-blur-sm">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<svg
className="w-4 h-4 text-indigo-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
User Profile
</h2>
{loading ? (
<p className="text-slate-400 text-xs">Loading...</p>
) : (
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-slate-500 block mb-0.5">Username</span>
<span className="text-slate-200 font-medium">
{userName || "—"}
</span>
</div>
<div>
<span className="text-slate-500 block mb-0.5">Email</span>
<span className="text-slate-200 truncate block font-medium">
{userEmail || "—"}
</span>
</div>
</div>
)}
</div>
);
};
export default UserProfile;

View File

@@ -0,0 +1,44 @@
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import fetchGetChatList from "../../features/fetch-async/fetchGetChatList";
import Sidebar from "./components/Sidebar";
import UserProfile from "./components/UserProfile";
import ChatArea from "./components/ChatArea";
const RagPage = () => {
const dispatch = useDispatch();
const chatList = useSelector((state) => state.chats.chatList);
const sortedChats = [...chatList].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt),
);
useEffect(() => {
if (chatList.length === 0) {
dispatch(fetchGetChatList());
}
}, [dispatch, chatList.length]);
return (
<div className="h-full bg-linear-to-br from-slate-950 via-slate-900 to-indigo-950 flex">
<Sidebar chats={sortedChats} />
<main className="flex-1 p-6 overflow-y-auto">
<div className="max-w-4xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl font-bold bg-linear-to-r from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
RAG Query
</h1>
<div className="h-1 w-24 bg-linear-to-r from-indigo-500 to-purple-500 rounded-full mt-2" />
</header>
<UserProfile />
<ChatArea />
</div>
</main>
</div>
);
};
export default RagPage;

View File

@@ -0,0 +1,10 @@
export const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};

View File

@@ -0,0 +1,23 @@
import { MAX_TITLE_LENGTH } from "../../../features/constants";
export const generateTitleFromMessage = (message) => {
const trimmed = message.trim();
const words = trimmed.split(/\s+/);
const firstThreeWords = words.slice(0, 3).join(" ");
if (firstThreeWords.length <= MAX_TITLE_LENGTH) {
return firstThreeWords;
}
// First 3 words exceed MAX_TITLE_LENGTH, truncate to MAX_TITLE_LENGTH
return trimmed.slice(0, MAX_TITLE_LENGTH);
};
/**
* Format title for display - add "..." if it was truncated
*/
export const formatTitleForDisplay = (title) => {
if (title.length === MAX_TITLE_LENGTH) {
return `${title}...`;
}
return title;
};

View File

@@ -0,0 +1,426 @@
import { useState, useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import fetchGetChatList from "../features/fetch-async/fetchGetChatList";
import {
switchSearchMode,
setTopK,
setTopP,
} from "../features/slices/rag-slice";
import fetchCreateNewChat from "../features/fetch-async/fetchCreateNewChat";
import {
TOP_K_MIN_VALUE,
TOP_K_DEFAULT_VALUE,
TOP_K_MAX_VALUE,
TOP_P_FAST_VALUE,
TOP_P_DEFAULT_VALUE,
TOP_P_SLOW_VALUE,
} from "../features/constants";
// Helper function to format date
const formatDate = (dateString) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
};
const RagPage = () => {
const dispatch = useDispatch();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isCreatingChat, setIsCreatingChat] = useState(false);
const [newChatTitle, setNewChatTitle] = useState("");
const chatList = useSelector((state) => state.chats.chatList);
// Sort chats: newest first (by createdAt descending)
const sortedChats = [...chatList].sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt),
);
useEffect(() => {
if (chatList.length === 0) {
dispatch(fetchGetChatList());
}
}, [dispatch, chatList.length]);
// User details selectors
const { userName, userEmail, loadedFiles, loading } = useSelector(
(state) => state.userDetails,
);
// RAG config selectors
const { isUseOnlyContextSearch, topK, topP } = useSelector(
(state) => state.ragConfig,
);
const handleSearchModeChange = () => {
dispatch(switchSearchMode());
};
const handleTopKChange = (value) => {
dispatch(setTopK(value));
};
const handleTopPChange = (value) => {
dispatch(setTopP(value));
};
const handleNewChatClick = () => {
setIsCreatingChat(true);
setNewChatTitle("");
};
const handleCreateChat = () => {
if (newChatTitle.trim()) {
dispatch(fetchCreateNewChat(newChatTitle.trim()));
setIsCreatingChat(false);
setNewChatTitle("");
}
};
const handleCancelCreate = () => {
setIsCreatingChat(false);
setNewChatTitle("");
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && newChatTitle.trim()) {
handleCreateChat();
} else if (e.key === "Escape") {
handleCancelCreate();
}
};
return (
<div className="h-full bg-linear-to-br from-slate-950 via-slate-900 to-indigo-950 flex">
{/* Left Sidebar */}
<aside className="w-64 bg-slate-900/80 border-r border-slate-700/50 flex flex-col backdrop-blur-sm">
{/* New Chat Button / Input */}
<div className="p-3">
{isCreatingChat ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newChatTitle}
onChange={(e) => setNewChatTitle(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleCancelCreate}
placeholder="Title"
autoFocus
className="flex-1 px-3 py-2.5 rounded-lg bg-slate-800 border border-slate-600 text-white placeholder-slate-400 text-sm focus:outline-none focus:border-indigo-500"
/>
{newChatTitle.trim() && (
<button
onMouseDown={(e) => e.preventDefault()}
onClick={handleCreateChat}
className="px-3 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors"
>
Ok
</button>
)}
</div>
) : (
<button
onClick={handleNewChatClick}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
New chat
</button>
)}
</div>
{/* Chats Section */}
<div className="flex-1 overflow-y-auto px-3">
<div className="flex items-center gap-2 px-2 py-2 text-slate-400">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<span className="text-sm font-medium">Chats</span>
</div>
{/* Chat List */}
<div className="space-y-1">
{sortedChats.map((chat) => (
<button
key={chat.id}
className="w-full text-left px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-800/60 hover:text-white transition-colors group"
>
<p className="text-sm truncate">{chat.title}</p>
<p className="text-xs text-slate-500 group-hover:text-slate-400">
{formatDate(chat.createdAt)}
</p>
</button>
))}
</div>
</div>
{/* Settings Dropdown at Bottom */}
<div className="p-3 pb-4 border-t border-slate-700/50 shrink-0 relative">
<button
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-slate-300 hover:bg-slate-800/60 hover:text-white transition-colors"
>
<div className="flex items-center gap-3">
<svg
className="w-5 h-5 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span className="text-sm font-medium">Settings</span>
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isSettingsOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</button>
{/* Settings Dropdown Content */}
{isSettingsOpen && (
<div className="mt-2 bg-slate-800/80 rounded-lg border border-slate-700/50 p-3 space-y-3">
{/* Search Mode */}
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wider">
Search Mode
</h4>
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="searchMode"
checked={isUseOnlyContextSearch}
onChange={handleSearchModeChange}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Only context</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="searchMode"
checked={!isUseOnlyContextSearch}
onChange={handleSearchModeChange}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Allow external</span>
</label>
</div>
</div>
{/* Top K */}
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wider">
Top K
</h4>
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="topK"
checked={topK === TOP_K_MIN_VALUE}
onChange={() => handleTopKChange(TOP_K_MIN_VALUE)}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Precise</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="topK"
checked={topK === TOP_K_DEFAULT_VALUE}
onChange={() => handleTopKChange(TOP_K_DEFAULT_VALUE)}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Default</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="topK"
checked={topK === TOP_K_MAX_VALUE}
onChange={() => handleTopKChange(TOP_K_MAX_VALUE)}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Extended</span>
</label>
</div>
</div>
{/* Top P */}
<div>
<h4 className="text-xs font-semibold text-slate-400 mb-2 uppercase tracking-wider">
Top P
</h4>
<div className="space-y-1.5">
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="topP"
checked={topP === TOP_P_FAST_VALUE}
onChange={() => handleTopPChange(TOP_P_FAST_VALUE)}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Fast</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="topP"
checked={topP === TOP_P_DEFAULT_VALUE}
onChange={() => handleTopPChange(TOP_P_DEFAULT_VALUE)}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Default</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs">
<input
type="radio"
name="topP"
checked={topP === TOP_P_SLOW_VALUE}
onChange={() => handleTopPChange(TOP_P_SLOW_VALUE)}
className="w-3 h-3 text-indigo-500 bg-slate-700 border-slate-600"
/>
<span className="text-slate-300">Large scan</span>
</label>
</div>
</div>
</div>
)}
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 p-6 overflow-y-auto">
<div className="max-w-4xl mx-auto">
{/* Header */}
<header className="mb-8">
<h1 className="text-3xl font-bold bg-linear-to-r from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">
RAG Query
</h1>
<div className="h-1 w-24 bg-linear-to-r from-indigo-500 to-purple-500 rounded-full mt-2" />
</header>
{/* User Info Section */}
<div className="bg-slate-800/60 rounded-xl p-4 border border-slate-700/40 mb-6 backdrop-blur-sm">
<h2 className="text-sm font-semibold text-slate-200 mb-3 flex items-center gap-2">
<svg
className="w-4 h-4 text-indigo-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
User Profile
</h2>
{loading ? (
<p className="text-slate-400 text-xs">Loading...</p>
) : (
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-slate-500 block mb-0.5">Username</span>
<span className="text-slate-200 font-medium">
{userName || "—"}
</span>
</div>
<div>
<span className="text-slate-500 block mb-0.5">Email</span>
<span className="text-slate-200 truncate block font-medium">
{userEmail || "—"}
</span>
</div>
</div>
)}
</div>
{/* Warning if no files */}
{(!loadedFiles || loadedFiles.length === 0) && (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-xl p-6 text-center">
<svg
className="w-12 h-12 text-amber-400 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<p className="text-amber-200 font-medium">
Unable to make RAG query
</p>
<p className="text-amber-300/70 text-sm mt-1">
Please upload at least one context file first
</p>
</div>
)}
{/* Chat area placeholder */}
{loadedFiles && loadedFiles.length > 0 && (
<div className="bg-slate-800/40 rounded-xl p-8 border border-slate-700/40 text-center">
<p className="text-slate-400">Start a conversation...</p>
</div>
)}
</div>
</main>
</div>
);
};
export default RagPage;

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import fetchRegisterUser from "../features/fetch-async/fetchRegisterUser";
import {
USER_NAME_UNDEFINED,
USER_EMAIL_UNDEFINED,
} from "../features/constants";
function RegisterPage() {
const [login, setLogin] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [validationError, setValidationError] = useState("");
const dispatch = useDispatch();
const navigate = useNavigate();
const { userName, userEmail, loading, error } = useSelector(
(state) => state.userDetails
);
useEffect(() => {
if (loading) return;
if (
userName !== USER_NAME_UNDEFINED &&
userEmail !== USER_EMAIL_UNDEFINED
) {
navigate("/");
}
}, [loading, userName, userEmail, navigate]);
const handleClear = () => {
setLogin("");
setEmail("");
setPassword("");
setConfirm("");
setValidationError("");
};
const handleSubmit = (e) => {
e.preventDefault();
setValidationError("");
if (login.length < 3) {
setValidationError("Login must be at least 3 characters.");
return;
}
if (password.length < 8) {
setValidationError("Password must be at least 8 characters.");
return;
}
if (password !== confirm) {
setValidationError("Password and Confirm password do not match.");
return;
}
const payload = {
username: login,
email: email,
password: password,
confirmPassword: confirm,
};
dispatch(fetchRegisterUser(payload));
};
return (
<div className="min-h-[60vh] flex items-center justify-center">
<div className="w-full max-w-md bg-slate-900/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-slate-700/60 p-8">
<h1 className="text-2xl font-semibold text-slate-50 text-center mb-2">
Register
</h1>
<p className="text-sm text-slate-400 text-center mb-6">
Create a new account by choosing a login, email and a strong password.
</p>
{(validationError || error) && (
<div className="mb-4 text-sm text-red-400 text-center">
{validationError || error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-200">
Login
</label>
<input
type="text"
value={login}
onChange={(e) => setLogin(e.target.value)}
placeholder="Your login"
className="w-full rounded-xl bg-slate-800/80 border border-slate-600 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/60 focus:outline-none px-4 py-2.5 text-slate-100 placeholder:text-slate-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-200">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Your email"
className="w-full rounded-xl bg-slate-800/80 border border-slate-600 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/60 focus:outline-none px-4 py-2.5 text-slate-100 placeholder:text-slate-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-200">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your password"
className="w-full rounded-xl bg-slate-800/80 border border-slate-600 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/60 focus:outline-none px-4 py-2.5 text-slate-100 placeholder:text-slate-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-200">
Confirm password
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="Repeat your password"
className="w-full rounded-xl bg-slate-800/80 border border-slate-600 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-500/60 focus:outline-none px-4 py-2.5 text-slate-100 placeholder:text-slate-500 text-sm"
/>
</div>
<div className="flex gap-3 mt-2">
<button
type="submit"
disabled={loading}
className="flex-1 inline-flex items-center justify-center rounded-xl bg-indigo-500 hover:bg-indigo-400 disabled:opacity-60 disabled:cursor-not-allowed active:bg-indigo-600 transition-colors px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-500/30"
>
{loading ? "Registering..." : "Register"}
</button>
{(validationError || error) && (
<button
type="button"
onClick={handleClear}
className="inline-flex items-center justify-center rounded-xl bg-slate-700 hover:bg-slate-600 active:bg-slate-800 transition-colors px-4 py-2.5 text-sm font-semibold text-slate-300"
>
Clear
</button>
)}
</div>
</form>
</div>
</div>
);
}
export default RegisterPage;

View File

@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
function RootLayout() {
return (
<div className="h-screen overflow-hidden bg-slate-950 text-slate-50">
<Outlet />
</div>
);
}
export default RootLayout;

View File

@@ -0,0 +1,25 @@
import { styles } from "../styles/uploadStyles";
const ErrorState = ({ error, onTryAgain, onGoHome }) => {
return (
<>
<div style={styles.errorBox}>
<span style={styles.errorIcon}></span>
<div>
<div style={styles.errorMessage}>{error.message}</div>
<div style={styles.errorStatus}>Status: {error.status}</div>
</div>
</div>
<div style={styles.buttonGroup}>
<button style={styles.button} onClick={onTryAgain}>
Try again
</button>
<button style={styles.buttonSecondary} onClick={onGoHome}>
Home
</button>
</div>
</>
);
};
export default ErrorState;

View File

@@ -0,0 +1,26 @@
import { styles } from "../styles/uploadStyles";
const FileList = ({ files }) => {
if (files.length === 0) return null;
return (
<div style={styles.selectedFilesContainer}>
<h4 style={styles.selectedFilesTitle}>
Selected files ({files.length}):
</h4>
<ul style={styles.fileList}>
{files.map((file, index) => (
<li key={index} style={styles.fileItem}>
<span style={styles.fileIcon}>📄</span>
<span style={styles.fileName}>{file.name}</span>
<span style={styles.fileSize}>
{(file.size / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</div>
);
};
export default FileList;

View File

@@ -0,0 +1,40 @@
import { useRef } from "react";
import { styles } from "../styles/uploadStyles";
const FileSelector = ({ disabled, onFilesChange }) => {
const fileInputRef = useRef(null);
const handleClick = () => {
if (fileInputRef.current) {
fileInputRef.current.value = null;
fileInputRef.current.click();
}
};
return (
<>
<button
style={{
...styles.uploadButton,
...(disabled && styles.buttonDisabled),
}}
onClick={handleClick}
disabled={disabled}
>
<span style={styles.uploadIcon}>📁</span>
Select files
</button>
<input
ref={fileInputRef}
type="file"
accept=".txt"
multiple
style={{ display: "none" }}
onChange={onFilesChange}
/>
</>
);
};
export default FileSelector;

View File

@@ -0,0 +1,38 @@
import { styles } from "../styles/uploadStyles";
const QuotaInfo = ({ loadedCount, maxFilesToLoad, remainingSlots }) => {
const percentage =
maxFilesToLoad > 0 ? (loadedCount / maxFilesToLoad) * 100 : 0;
return (
<div style={styles.quotaContainer}>
<div style={styles.quotaRow}>
<span style={styles.quotaLabel}>Files loaded:</span>
<span style={styles.quotaValue}>
{loadedCount} / {maxFilesToLoad}
</span>
</div>
<div style={styles.quotaRow}>
<span style={styles.quotaLabel}>Remaining slots:</span>
<span
style={{
...styles.quotaValue,
color: remainingSlots > 0 ? "#38a169" : "#e53e3e",
}}
>
{remainingSlots}
</span>
</div>
<div style={styles.quotaProgressContainer}>
<div
style={{
...styles.quotaProgressFill,
width: `${percentage}%`,
}}
/>
</div>
</div>
);
};
export default QuotaInfo;

View File

@@ -0,0 +1,27 @@
import { styles } from "../styles/uploadStyles";
const SuccessState = ({ filesCount, onUploadAgain, onGoHome }) => {
return (
<>
<div style={styles.successBox}>
<span style={styles.successIcon}></span>
<div>
<div style={styles.successMessage}>Upload completed!</div>
<div style={styles.successStats}>
{filesCount} of {filesCount} files processed
</div>
</div>
</div>
<div style={styles.buttonGroup}>
<button style={styles.button} onClick={onUploadAgain}>
Upload more
</button>
<button style={styles.buttonSecondary} onClick={onGoHome}>
Home
</button>
</div>
</>
);
};
export default SuccessState;

View File

@@ -0,0 +1,47 @@
import { getStatusIcon, getStatusText } from "../utils/statusHelpers";
import { styles } from "../styles/uploadStyles";
const UploadProgress = ({ progress, onCancel }) => {
return (
<div style={styles.progressContainer}>
<div style={styles.progressHeader}>
<span style={styles.progressIcon}>📤</span>
<span>Uploading files...</span>
</div>
<div style={styles.progressBarContainer}>
<div
style={{
...styles.progressBarFill,
width: `${progress.percent}%`,
}}
/>
</div>
<div style={styles.progressStats}>
<div style={styles.statItem}>
<span style={styles.statValue}>{progress.percent}%</span>
<span style={styles.statLabel}>Complete</span>
</div>
</div>
{progress.currentFile && (
<div style={styles.currentFile}>
<span>{getStatusIcon(progress.status)}</span>
<span style={styles.currentFileName}>{progress.currentFile}</span>
<span style={styles.currentFileStatus}>
{getStatusText(progress.status)}
</span>
</div>
)}
<div style={styles.cancelButtonContainer}>
<button style={styles.cancelButton} onClick={onCancel}>
Cancel Upload
</button>
</div>
</div>
);
};
export default UploadProgress;

View File

@@ -0,0 +1,257 @@
import { useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
uploadFilesWithProgress,
resetUploadState,
setAreFilesValid,
setQuotaError,
clearQuotaError,
cancelUpload,
} from "../../features/slices/upload-slice";
import {
MAX_FILE_SIZE_KB,
MAX_FILES_TO_UPLOAD,
} from "../../features/constants";
import UploadProgress from "./components/UploadProgress";
import ErrorState from "./components/ErrorState";
import SuccessState from "./components/SuccessState";
import QuotaInfo from "./components/QuotaInfo";
import FileSelector from "./components/FileSelector";
import FileList from "./components/FileList";
import { styles } from "./styles/uploadStyles";
export default function UploadPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const {
uploading,
error,
success,
areFilesValid,
quotaError,
progress,
uploadedFiles,
} = useSelector((state) => state.uploadFiles);
const loadedFiles = useSelector((state) => state.userDetails.loadedFiles);
const maxFilesToLoad = useSelector(
(state) => state.userDetails.maxFilesToLoad,
);
const loadedCount = loadedFiles?.length || 0;
const remainingSlots = Math.max(0, maxFilesToLoad - loadedCount);
const [selectedFiles, setSelectedFiles] = useState([]);
useEffect(() => {
if (!uploading && !success) {
dispatch(resetUploadState());
}
}, [dispatch, uploading, success]);
useEffect(() => {
if (success) {
const timer = setTimeout(() => {
dispatch(resetUploadState());
navigate("/");
}, 1500);
return () => clearTimeout(timer);
}
}, [success, dispatch, navigate]);
const handleFilesChange = (event) => {
const files = Array.from(event.target.files || []);
dispatch(clearQuotaError());
if (files.length > MAX_FILES_TO_UPLOAD) {
dispatch(resetUploadState());
dispatch(setAreFilesValid(false));
dispatch(
setQuotaError(
`You can upload maximum ${MAX_FILES_TO_UPLOAD} files at once`,
),
);
setSelectedFiles([]);
return;
}
if (files.length > remainingSlots) {
dispatch(resetUploadState());
dispatch(setAreFilesValid(false));
dispatch(
setQuotaError(
`You can only upload ${remainingSlots} more file(s). Already loaded: ${loadedCount}/${maxFilesToLoad}`,
),
);
setSelectedFiles([]);
return;
}
const isSizeCorrect = files.every(
(file) => file.size <= MAX_FILE_SIZE_KB * 1024,
);
if (!isSizeCorrect) {
dispatch(resetUploadState());
dispatch(setAreFilesValid(false));
dispatch(
setQuotaError(`Each file must be no more than ${MAX_FILE_SIZE_KB} KB`),
);
setSelectedFiles([]);
return;
}
dispatch(setAreFilesValid(true));
setSelectedFiles(files);
};
const handleSend = () => {
if (!selectedFiles.length || uploading) return;
dispatch(uploadFilesWithProgress(selectedFiles));
};
const handleClear = () => {
setSelectedFiles([]);
dispatch(clearQuotaError());
};
const handleCancel = () => {
dispatch(cancelUpload());
setSelectedFiles([]);
};
const handleTryAgain = () => {
dispatch(resetUploadState());
setSelectedFiles([]);
};
const handleGoHome = () => {
dispatch(resetUploadState());
navigate("/");
};
const handleUploadAgain = () => {
dispatch(resetUploadState());
setSelectedFiles([]);
};
// Loading state
if (uploading) {
return (
<div style={styles.container}>
<h1 style={styles.title}>
Upload text files for context searching in LLM
</h1>
<UploadProgress progress={progress} onCancel={handleCancel} />
</div>
);
}
// Error state
if (error.status !== undefined && error.status !== null) {
return (
<div style={styles.container}>
<h1 style={styles.title}>
Upload text files for context searching in LLM
</h1>
<ErrorState
error={error}
onTryAgain={handleTryAgain}
onGoHome={handleGoHome}
/>
</div>
);
}
// Success state
if (success) {
return (
<div style={styles.container}>
<h1 style={styles.title}>
Upload text files for context searching in LLM
</h1>
<SuccessState
filesCount={uploadedFiles.length}
onUploadAgain={handleUploadAgain}
onGoHome={handleGoHome}
/>
</div>
);
}
// Default state
return (
<div style={styles.container}>
<h3 style={styles.title}>
Upload text files for context searching in LLM
</h3>
<p style={styles.subtitle}>
Each file must be no more than {MAX_FILE_SIZE_KB} KB. You can upload up
to {MAX_FILES_TO_UPLOAD} files at once.
</p>
<QuotaInfo
loadedCount={loadedCount}
maxFilesToLoad={maxFilesToLoad}
remainingSlots={remainingSlots}
/>
{!areFilesValid && !quotaError && (
<div style={styles.warningBox}>
<span></span>
<span>File requirements not met. Please adjust your selection.</span>
</div>
)}
{quotaError && (
<div style={styles.warningBox}>
<span></span>
<span>{quotaError}</span>
</div>
)}
{remainingSlots === 0 && !quotaError && (
<div style={styles.errorBox}>
<span style={styles.errorIcon}>🚫</span>
<div>
<div style={styles.errorMessage}>
You have reached the maximum number of files
</div>
</div>
</div>
)}
<FileSelector
disabled={remainingSlots === 0}
onFilesChange={handleFilesChange}
/>
<FileList files={selectedFiles} />
<div style={styles.buttonGroup}>
<button
style={{
...styles.button,
...((!selectedFiles.length || uploading) && styles.buttonDisabled),
}}
onClick={handleSend}
disabled={!selectedFiles.length || uploading}
>
Send
</button>
<button
style={{
...styles.buttonSecondary,
...((!selectedFiles.length || uploading) && styles.buttonDisabled),
}}
onClick={handleClear}
disabled={!selectedFiles.length || uploading}
>
Clear
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,278 @@
export const styles = {
container: {
padding: "2rem",
maxWidth: "600px",
margin: "0 auto",
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
},
title: {
fontSize: "1.5rem",
fontWeight: 600,
color: "#1a1a2e",
marginBottom: "0.5rem",
},
subtitle: {
color: "#666",
marginBottom: "1.5rem",
lineHeight: 1.5,
},
quotaContainer: {
backgroundColor: "#f8f9fa",
borderRadius: "8px",
padding: "1rem",
marginBottom: "1rem",
border: "1px solid #e0e0e0",
},
quotaRow: {
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5rem",
},
quotaLabel: {
color: "#666",
fontSize: "0.9rem",
},
quotaValue: {
fontWeight: 500,
color: "#1a1a2e",
},
quotaProgressContainer: {
width: "100%",
height: "6px",
backgroundColor: "#e0e0e0",
borderRadius: "3px",
overflow: "hidden",
marginTop: "0.5rem",
},
quotaProgressFill: {
height: "100%",
backgroundColor: "#667eea",
borderRadius: "3px",
transition: "width 0.3s ease",
},
progressContainer: {
backgroundColor: "#f8f9fa",
borderRadius: "12px",
padding: "1.5rem",
marginTop: "1.5rem",
},
progressHeader: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "1.1rem",
fontWeight: 500,
marginBottom: "1rem",
},
progressIcon: {
fontSize: "1.5rem",
},
progressBarContainer: {
width: "100%",
height: "8px",
backgroundColor: "#e0e0e0",
borderRadius: "4px",
overflow: "hidden",
marginBottom: "1rem",
},
progressBarFill: {
height: "100%",
backgroundColor: "#4caf50",
borderRadius: "4px",
transition: "width 0.3s ease",
},
progressStats: {
display: "flex",
justifyContent: "center",
marginBottom: "1rem",
},
statItem: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
statValue: {
fontSize: "1.5rem",
fontWeight: 600,
color: "#1a1a2e",
},
statLabel: {
fontSize: "0.85rem",
color: "#666",
},
currentFile: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem",
backgroundColor: "#fff",
borderRadius: "8px",
border: "1px solid #e0e0e0",
},
currentFileName: {
flex: 1,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
currentFileStatus: {
fontSize: "0.85rem",
color: "#666",
},
cancelButtonContainer: {
display: "flex",
justifyContent: "center",
marginTop: "1.5rem",
},
cancelButton: {
padding: "0.75rem 2rem",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
backgroundColor: "#e53e3e",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.2s",
},
errorBox: {
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "1rem",
backgroundColor: "#fff5f5",
border: "1px solid #feb2b2",
borderRadius: "8px",
marginTop: "1rem",
marginBottom: "1rem",
},
errorIcon: {
fontSize: "2rem",
},
errorMessage: {
color: "#c53030",
fontWeight: 500,
},
errorStatus: {
color: "#666",
fontSize: "0.85rem",
},
successBox: {
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "1rem",
backgroundColor: "#f0fff4",
border: "1px solid #9ae6b4",
borderRadius: "8px",
marginTop: "1rem",
},
successIcon: {
fontSize: "2rem",
},
successMessage: {
color: "#276749",
fontWeight: 500,
fontSize: "1.1rem",
},
successStats: {
color: "#666",
fontSize: "0.9rem",
},
warningBox: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1rem",
backgroundColor: "#fffaf0",
border: "1px solid #fbd38d",
borderRadius: "8px",
color: "#c05621",
marginBottom: "1rem",
},
uploadButton: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
padding: "1rem",
fontSize: "1rem",
backgroundColor: "#f8f9fa",
border: "2px dashed #ccc",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
},
uploadIcon: {
fontSize: "1.25rem",
},
selectedFilesContainer: {
marginTop: "1.5rem",
padding: "1rem",
backgroundColor: "#f8f9fa",
borderRadius: "8px",
},
selectedFilesTitle: {
margin: "0 0 0.75rem 0",
fontSize: "1rem",
color: "#333",
},
fileList: {
listStyle: "none",
margin: 0,
padding: 0,
},
fileItem: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0",
borderBottom: "1px solid #e0e0e0",
},
fileIcon: {
fontSize: "1rem",
},
fileName: {
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
fileSize: {
color: "#666",
fontSize: "0.85rem",
},
buttonGroup: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
button: {
padding: "0.75rem 1.5rem",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
backgroundColor: "#4a5568",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.2s",
},
buttonSecondary: {
padding: "0.75rem 1.5rem",
fontSize: "1rem",
fontWeight: 500,
color: "#4a5568",
backgroundColor: "#e2e8f0",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.2s",
},
buttonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
};

View File

@@ -0,0 +1,36 @@
import {
PROGRESS_STATUS_PROCESSING,
PROGRESS_STATUS_COMPLETED,
PROGRESS_STATUS_ERROR,
PROGRESS_STATUS_SKIPPED,
} from "../../../features/constants";
export const getStatusIcon = (status) => {
switch (status) {
case PROGRESS_STATUS_PROCESSING:
return "⏳";
case PROGRESS_STATUS_COMPLETED:
return "✅";
case PROGRESS_STATUS_ERROR:
return "❌";
case PROGRESS_STATUS_SKIPPED:
return "⏭️";
default:
return "📄";
}
};
export const getStatusText = (status) => {
switch (status) {
case PROGRESS_STATUS_PROCESSING:
return "Processing...";
case PROGRESS_STATUS_COMPLETED:
return "Completed";
case PROGRESS_STATUS_ERROR:
return "Error";
case PROGRESS_STATUS_SKIPPED:
return "Skipped (duplicate)";
default:
return "Waiting...";
}
};

View File

@@ -0,0 +1,683 @@
import { useState, useRef, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import {
uploadFilesWithProgress,
resetUploadState,
setAreFilesValid,
setQuotaError,
clearQuotaError,
cancelUpload,
} from "../features/uploadFilesSlice";
import {
MAX_FILE_SIZE_KB,
MAX_FILES_TO_UPLOAD,
PROGRESS_STATUS_PROCESSING,
PROGRESS_STATUS_COMPLETED,
PROGRESS_STATUS_ERROR,
PROGRESS_STATUS_SKIPPED,
} from "../features/constants";
export default function UploadPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const {
uploading,
error,
success,
areFilesValid,
quotaError,
progress,
uploadedFiles,
} = useSelector((state) => state.uploadFiles);
const loadedFiles = useSelector((state) => state.userDetails.loadedFiles);
const maxFilesToLoad = useSelector(
(state) => state.userDetails.maxFilesToLoad
);
const loadedCount = loadedFiles?.length || 0;
const remainingSlots = Math.max(0, maxFilesToLoad - loadedCount);
const [selectedFiles, setSelectedFiles] = useState([]);
const fileInputRef = useRef(null);
useEffect(() => {
dispatch(resetUploadState());
}, [dispatch]);
const handleUploadClick = () => {
if (remainingSlots === 0) {
dispatch(setQuotaError("You have reached the maximum number of files"));
return;
}
if (fileInputRef.current) {
fileInputRef.current.value = null;
fileInputRef.current.click();
}
};
const handleFilesChange = (event) => {
const files = Array.from(event.target.files || []);
dispatch(clearQuotaError());
// Check max files per upload
if (files.length > MAX_FILES_TO_UPLOAD) {
dispatch(resetUploadState());
dispatch(setAreFilesValid(false));
dispatch(
setQuotaError(
`You can upload maximum ${MAX_FILES_TO_UPLOAD} files at once`
)
);
setSelectedFiles([]);
return;
}
// Check remaining slots
if (files.length > remainingSlots) {
dispatch(resetUploadState());
dispatch(setAreFilesValid(false));
dispatch(
setQuotaError(
`You can only upload ${remainingSlots} more file(s). Already loaded: ${loadedCount}/${maxFilesToLoad}`
)
);
setSelectedFiles([]);
return;
}
// Check file size
const isSizeCorrect = files.every(
(file) => file.size <= MAX_FILE_SIZE_KB * 1024
);
if (!isSizeCorrect) {
dispatch(resetUploadState());
dispatch(setAreFilesValid(false));
dispatch(
setQuotaError(`Each file must be no more than ${MAX_FILE_SIZE_KB} KB`)
);
setSelectedFiles([]);
return;
}
dispatch(setAreFilesValid(true));
setSelectedFiles(files);
};
const handleSend = () => {
if (!selectedFiles.length || uploading) return;
dispatch(uploadFilesWithProgress(selectedFiles));
};
const handleClear = () => {
setSelectedFiles([]);
dispatch(clearQuotaError());
};
const handleCancel = () => {
dispatch(cancelUpload());
setSelectedFiles([]);
};
const handleTryAgain = () => {
dispatch(resetUploadState());
setSelectedFiles([]);
};
const handleGoHome = () => {
dispatch(resetUploadState());
navigate("/");
};
const handleUploadAgain = () => {
dispatch(resetUploadState());
setSelectedFiles([]);
};
const getStatusIcon = (status) => {
switch (status) {
case PROGRESS_STATUS_PROCESSING:
return "⏳";
case PROGRESS_STATUS_COMPLETED:
return "✅";
case PROGRESS_STATUS_ERROR:
return "❌";
case PROGRESS_STATUS_SKIPPED:
return "⏭️";
default:
return "📄";
}
};
const getStatusText = (status) => {
switch (status) {
case PROGRESS_STATUS_PROCESSING:
return "Processing...";
case PROGRESS_STATUS_COMPLETED:
return "Completed";
case PROGRESS_STATUS_ERROR:
return "Error";
case PROGRESS_STATUS_SKIPPED:
return "Skipped (duplicate)";
default:
return "Waiting...";
}
};
// Loading state with progress
if (uploading) {
return (
<div style={styles.container}>
<h1 style={styles.title}>
Upload text files for context searching in LLM
</h1>
<div style={styles.progressContainer}>
<div style={styles.progressHeader}>
<span style={styles.progressIcon}>📤</span>
<span>Uploading files...</span>
</div>
<div style={styles.progressBarContainer}>
<div
style={{
...styles.progressBarFill,
width: `${progress.percent}%`,
}}
/>
</div>
<div style={styles.progressStats}>
<div style={styles.statItem}>
<span style={styles.statValue}>{progress.percent}%</span>
<span style={styles.statLabel}>Complete</span>
</div>
</div>
{progress.currentFile && (
<div style={styles.currentFile}>
<span>{getStatusIcon(progress.status)}</span>
<span style={styles.currentFileName}>{progress.currentFile}</span>
<span style={styles.currentFileStatus}>
{getStatusText(progress.status)}
</span>
</div>
)}
<div style={styles.cancelButtonContainer}>
<button style={styles.cancelButton} onClick={handleCancel}>
Cancel Upload
</button>
</div>
</div>
</div>
);
}
// Error state
if (error.status !== undefined && error.status !== null) {
return (
<div style={styles.container}>
<h1 style={styles.title}>
Upload text files for context searching in LLM
</h1>
<div style={styles.errorBox}>
<span style={styles.errorIcon}></span>
<div>
<div style={styles.errorMessage}>{error.message}</div>
<div style={styles.errorStatus}>Status: {error.status}</div>
</div>
</div>
<div style={styles.buttonGroup}>
<button style={styles.button} onClick={handleTryAgain}>
Try again
</button>
<button style={styles.buttonSecondary} onClick={handleGoHome}>
Home
</button>
</div>
</div>
);
}
// Success state
if (success) {
const filesCount = uploadedFiles.length;
return (
<div style={styles.container}>
<h1 style={styles.title}>
Upload text files for context searching in LLM
</h1>
<div style={styles.successBox}>
<span style={styles.successIcon}></span>
<div>
<div style={styles.successMessage}>Upload completed!</div>
<div style={styles.successStats}>
{filesCount} of {filesCount} files processed
</div>
</div>
</div>
<div style={styles.buttonGroup}>
<button style={styles.button} onClick={handleUploadAgain}>
Upload more
</button>
<button style={styles.buttonSecondary} onClick={handleGoHome}>
Home
</button>
</div>
</div>
);
}
// Default state - file selection
return (
<div style={styles.container}>
<h3 style={styles.title}>
Upload text files for context searching in LLM
</h3>
<p style={styles.subtitle}>
Each file must be no more than {MAX_FILE_SIZE_KB} KB. You can upload up
to {MAX_FILES_TO_UPLOAD} files at once.
</p>
{/* File quota info */}
<div style={styles.quotaContainer}>
<div style={styles.quotaRow}>
<span style={styles.quotaLabel}>Files loaded:</span>
<span style={styles.quotaValue}>
{loadedCount} / {maxFilesToLoad}
</span>
</div>
<div style={styles.quotaRow}>
<span style={styles.quotaLabel}>Remaining slots:</span>
<span
style={{
...styles.quotaValue,
color: remainingSlots > 0 ? "#38a169" : "#e53e3e",
}}
>
{remainingSlots}
</span>
</div>
<div style={styles.quotaProgressContainer}>
<div
style={{
...styles.quotaProgressFill,
width: `${
maxFilesToLoad > 0 ? (loadedCount / maxFilesToLoad) * 100 : 0
}%`,
}}
/>
</div>
</div>
{!areFilesValid && !quotaError && (
<div style={styles.warningBox}>
<span></span>
<span>File requirements not met. Please adjust your selection.</span>
</div>
)}
{quotaError && (
<div style={styles.warningBox}>
<span></span>
<span>{quotaError}</span>
</div>
)}
{remainingSlots === 0 && !quotaError && (
<div style={styles.errorBox}>
<span style={styles.errorIcon}>🚫</span>
<div>
<div style={styles.errorMessage}>
You have reached the maximum number of files
</div>
</div>
</div>
)}
<button
style={{
...styles.uploadButton,
...(remainingSlots === 0 && styles.buttonDisabled),
}}
onClick={handleUploadClick}
disabled={remainingSlots === 0}
>
<span style={styles.uploadIcon}>📁</span>
Select files
</button>
<input
ref={fileInputRef}
type="file"
accept=".txt"
multiple
style={{ display: "none" }}
onChange={handleFilesChange}
/>
{selectedFiles.length > 0 && (
<div style={styles.selectedFilesContainer}>
<h4 style={styles.selectedFilesTitle}>
Selected files ({selectedFiles.length}):
</h4>
<ul style={styles.fileList}>
{selectedFiles.map((file, index) => (
<li key={index} style={styles.fileItem}>
<span style={styles.fileIcon}>📄</span>
<span style={styles.fileName}>{file.name}</span>
<span style={styles.fileSize}>
{(file.size / 1024).toFixed(1)} KB
</span>
</li>
))}
</ul>
</div>
)}
<div style={styles.buttonGroup}>
<button
style={{
...styles.button,
...((!selectedFiles.length || uploading) && styles.buttonDisabled),
}}
onClick={handleSend}
disabled={!selectedFiles.length || uploading}
>
Send
</button>
<button
style={{
...styles.buttonSecondary,
...((!selectedFiles.length || uploading) && styles.buttonDisabled),
}}
onClick={handleClear}
disabled={!selectedFiles.length || uploading}
>
Clear
</button>
</div>
</div>
);
}
const styles = {
container: {
padding: "2rem",
maxWidth: "600px",
margin: "0 auto",
fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif",
},
title: {
fontSize: "1.5rem",
fontWeight: 600,
color: "#1a1a2e",
marginBottom: "0.5rem",
},
subtitle: {
color: "#666",
marginBottom: "1.5rem",
lineHeight: 1.5,
},
quotaContainer: {
backgroundColor: "#f8f9fa",
borderRadius: "8px",
padding: "1rem",
marginBottom: "1rem",
border: "1px solid #e0e0e0",
},
quotaRow: {
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5rem",
},
quotaLabel: {
color: "#666",
fontSize: "0.9rem",
},
quotaValue: {
fontWeight: 500,
color: "#1a1a2e",
},
quotaProgressContainer: {
width: "100%",
height: "6px",
backgroundColor: "#e0e0e0",
borderRadius: "3px",
overflow: "hidden",
marginTop: "0.5rem",
},
quotaProgressFill: {
height: "100%",
backgroundColor: "#667eea",
borderRadius: "3px",
transition: "width 0.3s ease",
},
progressContainer: {
backgroundColor: "#f8f9fa",
borderRadius: "12px",
padding: "1.5rem",
marginTop: "1.5rem",
},
progressHeader: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
fontSize: "1.1rem",
fontWeight: 500,
marginBottom: "1rem",
},
progressIcon: {
fontSize: "1.5rem",
},
progressBarContainer: {
width: "100%",
height: "8px",
backgroundColor: "#e0e0e0",
borderRadius: "4px",
overflow: "hidden",
marginBottom: "1rem",
},
progressBarFill: {
height: "100%",
backgroundColor: "#4caf50",
borderRadius: "4px",
transition: "width 0.3s ease",
},
progressStats: {
display: "flex",
justifyContent: "center",
marginBottom: "1rem",
},
statItem: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
statValue: {
fontSize: "1.5rem",
fontWeight: 600,
color: "#1a1a2e",
},
statLabel: {
fontSize: "0.85rem",
color: "#666",
},
currentFile: {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem",
backgroundColor: "#fff",
borderRadius: "8px",
border: "1px solid #e0e0e0",
},
currentFileName: {
flex: 1,
fontWeight: 500,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
currentFileStatus: {
fontSize: "0.85rem",
color: "#666",
},
cancelButtonContainer: {
display: "flex",
justifyContent: "center",
marginTop: "1.5rem",
},
cancelButton: {
padding: "0.75rem 2rem",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
backgroundColor: "#e53e3e",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.2s",
},
errorBox: {
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "1rem",
backgroundColor: "#fff5f5",
border: "1px solid #feb2b2",
borderRadius: "8px",
marginTop: "1rem",
marginBottom: "1rem",
},
errorIcon: {
fontSize: "2rem",
},
errorMessage: {
color: "#c53030",
fontWeight: 500,
},
errorStatus: {
color: "#666",
fontSize: "0.85rem",
},
successBox: {
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "1rem",
backgroundColor: "#f0fff4",
border: "1px solid #9ae6b4",
borderRadius: "8px",
marginTop: "1rem",
},
successIcon: {
fontSize: "2rem",
},
successMessage: {
color: "#276749",
fontWeight: 500,
fontSize: "1.1rem",
},
successStats: {
color: "#666",
fontSize: "0.9rem",
},
warningBox: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1rem",
backgroundColor: "#fffaf0",
border: "1px solid #fbd38d",
borderRadius: "8px",
color: "#c05621",
marginBottom: "1rem",
},
uploadButton: {
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
padding: "1rem",
fontSize: "1rem",
backgroundColor: "#f8f9fa",
border: "2px dashed #ccc",
borderRadius: "8px",
cursor: "pointer",
transition: "all 0.2s",
},
uploadIcon: {
fontSize: "1.25rem",
},
selectedFilesContainer: {
marginTop: "1.5rem",
padding: "1rem",
backgroundColor: "#f8f9fa",
borderRadius: "8px",
},
selectedFilesTitle: {
margin: "0 0 0.75rem 0",
fontSize: "1rem",
color: "#333",
},
fileList: {
listStyle: "none",
margin: 0,
padding: 0,
},
fileItem: {
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.5rem 0",
borderBottom: "1px solid #e0e0e0",
},
fileIcon: {
fontSize: "1rem",
},
fileName: {
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
fileSize: {
color: "#666",
fontSize: "0.85rem",
},
buttonGroup: {
display: "flex",
gap: "0.75rem",
marginTop: "1.5rem",
},
button: {
padding: "0.75rem 1.5rem",
fontSize: "1rem",
fontWeight: 500,
color: "#fff",
backgroundColor: "#4a5568",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.2s",
},
buttonSecondary: {
padding: "0.75rem 1.5rem",
fontSize: "1rem",
fontWeight: 500,
color: "#4a5568",
backgroundColor: "#e2e8f0",
border: "none",
borderRadius: "6px",
cursor: "pointer",
transition: "background-color 0.2s",
},
buttonDisabled: {
opacity: 0.5,
cursor: "not-allowed",
},
};

16
rag-view/src/store.js Normal file
View File

@@ -0,0 +1,16 @@
import { configureStore } from "@reduxjs/toolkit";
import userDetailsReducer from "./features/slices/details-slice";
import uploadFilesReducer from "./features/slices/upload-slice";
import ragSliceReducer from "./features/slices/rag-slice";
import chatSliceReducer from "./features/slices/chat-slice";
const store = configureStore({
reducer: {
userDetails: userDetailsReducer,
uploadFiles: uploadFilesReducer,
ragConfig: ragSliceReducer,
chats: chatSliceReducer,
},
});
export default store;

7
rag-view/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/ragview/",
});