add rag-view frontend
This commit is contained in:
@@ -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
26
rag-view/.gitignore
vendored
Normal 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
12
rag-view/README.md
Normal 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.
|
||||||
11
rag-view/docker/Dockerfile
Normal file
11
rag-view/docker/Dockerfile
Normal 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
|
||||||
8
rag-view/docker/nginx.conf
Normal file
8
rag-view/docker/nginx.conf
Normal 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
29
rag-view/eslint.config.js
Normal 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
13
rag-view/index.html
Normal 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
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
37
rag-view/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
rag-view/postcss.config.js
Normal file
5
rag-view/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
31
rag-view/public/context-questions.txt
Normal file
31
rag-view/public/context-questions.txt
Normal 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"
|
||||||
30
rag-view/public/guest-example.txt
Normal file
30
rag-view/public/guest-example.txt
Normal 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
50
rag-view/src/App.jsx
Normal 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;
|
||||||
54
rag-view/src/features/constants.js
Normal file
54
rag-view/src/features/constants.js
Normal 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",
|
||||||
|
};
|
||||||
44
rag-view/src/features/fetch-async/fetchAddNewUserEntry.js
Normal file
44
rag-view/src/features/fetch-async/fetchAddNewUserEntry.js
Normal 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;
|
||||||
43
rag-view/src/features/fetch-async/fetchCreateNewChat.js
Normal file
43
rag-view/src/features/fetch-async/fetchCreateNewChat.js
Normal 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;
|
||||||
50
rag-view/src/features/fetch-async/fetchDeleteChat.js
Normal file
50
rag-view/src/features/fetch-async/fetchDeleteChat.js
Normal 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;
|
||||||
33
rag-view/src/features/fetch-async/fetchDeleteUploaded.js
Normal file
33
rag-view/src/features/fetch-async/fetchDeleteUploaded.js
Normal 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;
|
||||||
40
rag-view/src/features/fetch-async/fetchGetChat.js
Normal file
40
rag-view/src/features/fetch-async/fetchGetChat.js
Normal 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;
|
||||||
40
rag-view/src/features/fetch-async/fetchGetChatList.js
Normal file
40
rag-view/src/features/fetch-async/fetchGetChatList.js
Normal 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;
|
||||||
54
rag-view/src/features/fetch-async/fetchLoginUser.js
Normal file
54
rag-view/src/features/fetch-async/fetchLoginUser.js
Normal 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;
|
||||||
54
rag-view/src/features/fetch-async/fetchRefreshToken.js
Normal file
54
rag-view/src/features/fetch-async/fetchRefreshToken.js
Normal 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;
|
||||||
52
rag-view/src/features/fetch-async/fetchRegisterUser.js
Normal file
52
rag-view/src/features/fetch-async/fetchRegisterUser.js
Normal 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;
|
||||||
35
rag-view/src/features/fetch-async/fetchUserProfile.js
Normal file
35
rag-view/src/features/fetch-async/fetchUserProfile.js
Normal 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;
|
||||||
46
rag-view/src/features/fetch-async/fetchWithAuth.js
Normal file
46
rag-view/src/features/fetch-async/fetchWithAuth.js
Normal 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 });
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
26
rag-view/src/features/slices/chat-slice/index.js
Normal file
26
rag-view/src/features/slices/chat-slice/index.js
Normal 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;
|
||||||
9
rag-view/src/features/slices/chat-slice/initialState.js
Normal file
9
rag-view/src/features/slices/chat-slice/initialState.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
8
rag-view/src/features/slices/chat-slice/reducers.js
Normal file
8
rag-view/src/features/slices/chat-slice/reducers.js
Normal 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 || [];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
7
rag-view/src/features/slices/details-slice/index.js
Normal file
7
rag-view/src/features/slices/details-slice/index.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
logoutUser,
|
||||||
|
clearError,
|
||||||
|
clearDeleteStatus,
|
||||||
|
//addLoadedFiles,
|
||||||
|
default,
|
||||||
|
} from "./userDetailsSlice";
|
||||||
36
rag-view/src/features/slices/details-slice/initialState.js
Normal file
36
rag-view/src/features/slices/details-slice/initialState.js
Normal 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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
};
|
||||||
26
rag-view/src/features/slices/details-slice/reducers.js
Normal file
26
rag-view/src/features/slices/details-slice/reducers.js
Normal 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];
|
||||||
|
// },
|
||||||
|
};
|
||||||
33
rag-view/src/features/slices/details-slice/tokenHelpers.js
Normal file
33
rag-view/src/features/slices/details-slice/tokenHelpers.js
Normal 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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
13
rag-view/src/features/slices/rag-slice/index.js
Normal file
13
rag-view/src/features/slices/rag-slice/index.js
Normal 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;
|
||||||
9
rag-view/src/features/slices/rag-slice/initialState.js
Normal file
9
rag-view/src/features/slices/rag-slice/initialState.js
Normal 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,
|
||||||
|
};
|
||||||
9
rag-view/src/features/slices/rag-slice/reducers.js
Normal file
9
rag-view/src/features/slices/rag-slice/reducers.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export const reducers = {
|
||||||
|
switchSearchMode(state) {
|
||||||
|
state.isUseOnlyContextSearch = !state.isUseOnlyContextSearch;
|
||||||
|
},
|
||||||
|
|
||||||
|
setTopP(state, action) {
|
||||||
|
state.topP = action.payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
19
rag-view/src/features/slices/upload-slice/abortController.js
Normal file
19
rag-view/src/features/slices/upload-slice/abortController.js
Normal 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;
|
||||||
|
};
|
||||||
48
rag-view/src/features/slices/upload-slice/index.js
Normal file
48
rag-view/src/features/slices/upload-slice/index.js
Normal 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;
|
||||||
18
rag-view/src/features/slices/upload-slice/initialState.js
Normal file
18
rag-view/src/features/slices/upload-slice/initialState.js
Normal 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: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
62
rag-view/src/features/slices/upload-slice/reducers.js
Normal file
62
rag-view/src/features/slices/upload-slice/reducers.js
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
74
rag-view/src/features/slices/upload-slice/selectors.js
Normal file
74
rag-view/src/features/slices/upload-slice/selectors.js
Normal 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;
|
||||||
|
};
|
||||||
190
rag-view/src/features/slices/upload-slice/thunks.js
Normal file
190
rag-view/src/features/slices/upload-slice/thunks.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
333
rag-view/src/features/uploadFilesSliceOld.js
Normal file
333
rag-view/src/features/uploadFilesSliceOld.js
Normal 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;
|
||||||
199
rag-view/src/features/userDetailsSliceOld.js
Normal file
199
rag-view/src/features/userDetailsSliceOld.js
Normal 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
1
rag-view/src/index.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
15
rag-view/src/main.jsx
Normal file
15
rag-view/src/main.jsx
Normal 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>
|
||||||
|
);
|
||||||
300
rag-view/src/pages/HomePage.jsx
Normal file
300
rag-view/src/pages/HomePage.jsx
Normal 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;
|
||||||
136
rag-view/src/pages/LoginPage.jsx
Normal file
136
rag-view/src/pages/LoginPage.jsx
Normal 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;
|
||||||
189
rag-view/src/pages/RagPage/components/ChatArea.jsx
Normal file
189
rag-view/src/pages/RagPage/components/ChatArea.jsx
Normal 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;
|
||||||
102
rag-view/src/pages/RagPage/components/ChatList.jsx
Normal file
102
rag-view/src/pages/RagPage/components/ChatList.jsx
Normal 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;
|
||||||
90
rag-view/src/pages/RagPage/components/NewChatButton.jsx
Normal file
90
rag-view/src/pages/RagPage/components/NewChatButton.jsx
Normal 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;
|
||||||
127
rag-view/src/pages/RagPage/components/SettingsPanel.jsx
Normal file
127
rag-view/src/pages/RagPage/components/SettingsPanel.jsx
Normal 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;
|
||||||
15
rag-view/src/pages/RagPage/components/Sidebar.jsx
Normal file
15
rag-view/src/pages/RagPage/components/Sidebar.jsx
Normal 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;
|
||||||
48
rag-view/src/pages/RagPage/components/UserProfile.jsx
Normal file
48
rag-view/src/pages/RagPage/components/UserProfile.jsx
Normal 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;
|
||||||
44
rag-view/src/pages/RagPage/index.jsx
Normal file
44
rag-view/src/pages/RagPage/index.jsx
Normal 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;
|
||||||
10
rag-view/src/pages/RagPage/utils/formatDate.js
Normal file
10
rag-view/src/pages/RagPage/utils/formatDate.js
Normal 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" });
|
||||||
|
};
|
||||||
23
rag-view/src/pages/RagPage/utils/titleUtils
Normal file
23
rag-view/src/pages/RagPage/utils/titleUtils
Normal 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;
|
||||||
|
};
|
||||||
426
rag-view/src/pages/RagPageOld.jsx
Normal file
426
rag-view/src/pages/RagPageOld.jsx
Normal 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;
|
||||||
163
rag-view/src/pages/RegisterPage.jsx
Normal file
163
rag-view/src/pages/RegisterPage.jsx
Normal 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;
|
||||||
11
rag-view/src/pages/RootLayout.jsx
Normal file
11
rag-view/src/pages/RootLayout.jsx
Normal 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;
|
||||||
25
rag-view/src/pages/UploadPage/components/ErrorState.jsx
Normal file
25
rag-view/src/pages/UploadPage/components/ErrorState.jsx
Normal 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;
|
||||||
26
rag-view/src/pages/UploadPage/components/FileList.jsx
Normal file
26
rag-view/src/pages/UploadPage/components/FileList.jsx
Normal 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;
|
||||||
40
rag-view/src/pages/UploadPage/components/FileSelector.jsx
Normal file
40
rag-view/src/pages/UploadPage/components/FileSelector.jsx
Normal 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;
|
||||||
38
rag-view/src/pages/UploadPage/components/QuotaInfo.jsx
Normal file
38
rag-view/src/pages/UploadPage/components/QuotaInfo.jsx
Normal 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;
|
||||||
27
rag-view/src/pages/UploadPage/components/SuccessState.jsx
Normal file
27
rag-view/src/pages/UploadPage/components/SuccessState.jsx
Normal 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;
|
||||||
47
rag-view/src/pages/UploadPage/components/UploadProgress.jsx
Normal file
47
rag-view/src/pages/UploadPage/components/UploadProgress.jsx
Normal 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;
|
||||||
257
rag-view/src/pages/UploadPage/index.jsx
Normal file
257
rag-view/src/pages/UploadPage/index.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
278
rag-view/src/pages/UploadPage/styles/uploadStyles.js
Normal file
278
rag-view/src/pages/UploadPage/styles/uploadStyles.js
Normal 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",
|
||||||
|
},
|
||||||
|
};
|
||||||
36
rag-view/src/pages/UploadPage/utils/statusHelpers.js
Normal file
36
rag-view/src/pages/UploadPage/utils/statusHelpers.js
Normal 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...";
|
||||||
|
}
|
||||||
|
};
|
||||||
683
rag-view/src/pages/UploadPageOld.jsx
Normal file
683
rag-view/src/pages/UploadPageOld.jsx
Normal 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
16
rag-view/src/store.js
Normal 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
7
rag-view/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: "/ragview/",
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user