first commit

This commit is contained in:
Stefano Rossi 2025-07-10 01:43:01 +02:00
parent 5c5d88c92f
commit eb4f62c56d
Signed by: chadmin
GPG key ID: 9EFA2130646BC893
41 changed files with 3851 additions and 19 deletions

View file

@ -0,0 +1,4 @@
**/node_modules
**/out/
**/.history/
**/__pycache__/

View file

@ -0,0 +1,37 @@
# Use the official Node.js image as a base
FROM node:20
# Set the working directory inside the container
WORKDIR /app
# Install pnpm globally with setup
ENV PNPM_HOME=/usr/local/bin
RUN npm install -g pnpm
# Copy package.json and package-lock.json to the working directory
COPY app/package*.json ./
# Install dependencies using pnpm
RUN pnpm install
# Set environment variables for the build
ARG REACT_APP_API_BASE_URL
ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL:-http://rag-service:8000}
# Copy specific folders to avoid node_modules
COPY app/public ./public
COPY app/src ./src
COPY app/*.json ./
# Build the React application with pnpm - show env vars for debugging
RUN echo "Building with API URL: $REACT_APP_API_BASE_URL" && \
pnpm run build
# Use npm for global installs (more reliable in Docker)
RUN npm install -g serve
# Expose the port the app runs on
EXPOSE 3000
# Command to run the application
CMD ["serve", "-s", "build"]

View file

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

View file

@ -0,0 +1,28 @@
server {
listen 80;
# Compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Root directory and index file
root /usr/share/nginx/html;
index index.html index.htm;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View file

@ -0,0 +1,42 @@
{
"name": "real-time-chat-app",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4",
"websocket": "^1.0.35",
"react-syntax-highlighter": "^15.5.0",
"react-markdown": "^7.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,912 @@
import React, { useEffect, useRef, useState } from 'react';
import './App.css';
// Code highlighting support:
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
// Markdown parsing:
import ReactMarkdown from 'react-markdown';
// Custom renderer for code blocks to use our SyntaxHighlighter
const MarkdownComponents = {
code({node, inline, className, children, ...props}) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={tomorrow}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className} {...props}>
{children}
</code>
);
}
};
// Helper to format messages using ReactMarkdown to support Markdown syntax and code highlighting
const formatMessage = (content) => {
return (
<ReactMarkdown components={MarkdownComponents}>
{content}
</ReactMarkdown>
);
};
// New helper to process thinking content
const processThinkingContent = (message) => {
// Check if the message contains thinking tags
const thinkPattern = /<think>([\s\S]*?)<\/think>/;
const thinkMatch = message.match(thinkPattern);
if (thinkMatch) {
// Extract thinking part and remaining content
const thinkingContent = thinkMatch[1].trim();
const regularContent = message.replace(thinkPattern, '').trim();
return {
hasThinking: true,
thinking: thinkingContent,
response: regularContent
};
}
return {
hasThinking: false,
thinking: '',
response: message
};
};
// Determine API base URL from environment or use dynamic fallbacks
const getApiBaseUrl = () => {
// When running locally, use localhost port (as published by docker-compose)
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
console.log('Using local API URL: http://localhost:8000');
return 'http://localhost:8000';
}
// Otherwise, if an environment variable is defined, use that
if (process.env.REACT_APP_API_BASE_URL) {
console.log(`Using API URL from env: ${process.env.REACT_APP_API_BASE_URL}`);
return process.env.REACT_APP_API_BASE_URL;
}
// Fallback to relative URLs (for production with reverse proxy)
console.log('Using relative URL for API');
return '';
};
// Moon and Sun icons for theme toggle
const MoonIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
);
const SunIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
);
// Icone per l'invio e il caricamento
const SendIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
);
const LoadingIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="loading-spinner">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 6v6l4 2"></path>
</svg>
);
const AttachmentIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21.44 11.05l-9.19 9.19a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.19 9.19a2.5 2.5 0 0 1-3.54-3.54l8.48-8.48"></path>
</svg>
);
// Icona cervello attivo per la modalità "thinking"
const ThinkingIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44A2.5 2.5 0 0 1 5.5 17v-2.5a2.5 2.5 0 0 1-.64-4.9 2.5 2.5 0 0 1 2.14-4.5 2.5 2.5 0 0 1 4.5-3"></path>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44A2.5 2.5 0 0 0 18.5 17v-2.5a2.5 2.5 0 0 0 .64-4.9 2.5 2.5 0 0 0-2.14-4.5 2.5 2.5 0 0 0-4.5-3"></path>
</svg>
);
// Icona cervello disattivato (con traccia sopra)
const ThinkingDisabledIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96.44A2.5 2.5 0 0 1 5.5 17v-2.5a2.5 2.5 0 0 1-.64-4.9 2.5 2.5 0 0 1 2.14-4.5 2.5 2.5 0 0 1 4.5-3"></path>
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96.44A2.5 2.5 0 0 0 18.5 17v-2.5a2.5 2.5 0 0 0 .64-4.9 2.5 2.5 0 0 0-2.14-4.5 2.5 2.5 0 0 0-4.5-3"></path>
<line x1="2" y1="2" x2="22" y2="22" strokeWidth="1.5"></line>
</svg>
);
// Add Trash icon for delete functionality
const TrashIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
// Replace the Clear History icon with a chat bubble icon
const ClearHistoryIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
<line x1="9" y1="10" x2="15" y2="10"></line>
<line x1="12" y1="7" x2="12" y2="13"></line>
</svg>
);
// Aggiungiamo le icone per le personalità
const PersonalityIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
</svg>
);
// Aggiorniamo le icone per le personalità con versioni più moderne
const CoolIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 14s1.5 2 4 2 4-2 4-2"></path>
<line x1="8" y1="9" x2="9" y2="9"></line>
<line x1="15" y1="9" x2="16" y2="9"></line>
<path d="M3 8l2-1"></path>
<path d="M21 8l-2-1"></path>
</svg>
);
const CynicalIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 16s1.5 -1 4 -1 4 1 4 1"></path>
<line x1="9" y1="9" x2="9.01" y2="9"></line>
<line x1="15" y1="9" x2="15.01" y2="9"></line>
<path d="M17 5l2 -2"></path>
<path d="M7 5l-2 -2"></path>
</svg>
);
const SupportiveIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M8 13s1.5 2 4 2 4-2 4-2"></path>
<path d="M9 9h.01"></path>
<path d="M15 9h.01"></path>
<path d="M12 7v0.01"></path>
<path d="M12 17v.01"></path>
<path d="M12 3v2"></path>
<path d="M12 19v2"></path>
</svg>
);
function App() {
const [messages, setMessages] = useState(() => {
// Carica i messaggi precedenti dal localStorage o inizializza un array vuoto
const savedMessages = localStorage.getItem('chatMessages');
return savedMessages ? JSON.parse(savedMessages) : [];
});
const [messageInput, setMessageInput] = useState('');
const [language, setLanguage] = useState(() => {
// Carica la lingua precedente dal localStorage o usa auto-detect come predefinito
return localStorage.getItem('selectedLanguage') || 'auto';
});
const [isLoading, setIsLoading] = useState(false);
// Aggiungo un ref per il container dei messaggi per gestire lo scroll
const messagesEndRef = useRef(null);
const [darkTheme, setDarkTheme] = useState(() => {
// Carica il tema precedente dal localStorage o usa dark come predefinito
const savedTheme = localStorage.getItem('darkTheme');
return savedTheme !== null ? JSON.parse(savedTheme) : true;
});
const fileInputRef = useRef(null);
const [reasoning, setReasoning] = useState(() => {
// Carica lo stato reasoning precedente dal localStorage
const savedReasoning = localStorage.getItem('reasoningEnabled');
return savedReasoning !== null ? JSON.parse(savedReasoning) : false;
});
const [isDeleting, setIsDeleting] = useState(false);
const apiBaseUrl = getApiBaseUrl();
// Nomi localizzati delle lingue
const languageLabels = {
french: "Français",
italian: "Italiano",
english: "English",
german: "Deutsch",
auto: "Auto-detect"
};
const [personality, setPersonality] = useState(() => {
// Carica la personalità precedente dal localStorage o usa supportive come predefinito
const savedPersonality = localStorage.getItem('personalityType');
return savedPersonality || 'supportive';
});
// Rimuovo il dropdown state che non serve più
// const [showPersonalityDropdown, setShowPersonalityDropdown] = useState(false);
// Effect to set initial theme and adjust container height
useEffect(() => {
// Apply theme class on first load
if (darkTheme) {
document.body.classList.add('dark-theme');
} else {
document.body.classList.remove('dark-theme');
}
// Save theme to localStorage
localStorage.setItem('darkTheme', JSON.stringify(darkTheme));
// Function to adjust chat container height
const adjustHeight = () => {
const vh = window.innerHeight;
const headerHeight = document.querySelector('.App-header')?.offsetHeight || 0;
const messagesContainer = document.querySelector('.messages-container');
if (messagesContainer) {
const inputContainer = document.querySelector('.input-container')?.offsetHeight || 0;
const fileUpload = document.querySelector('.file-upload')?.offsetHeight || 0;
const availableHeight = vh - headerHeight - inputContainer - fileUpload - 60; // 60px for padding/margins
messagesContainer.style.height = `${Math.max(400, availableHeight)}px`;
}
};
// Run once and add event listener for resize
adjustHeight();
window.addEventListener('resize', adjustHeight);
// Cleanup
return () => window.removeEventListener('resize', adjustHeight);
}, [darkTheme]);
// Save messages to localStorage whenever they change
useEffect(() => {
localStorage.setItem('chatMessages', JSON.stringify(messages));
}, [messages]);
// Toggle between light and dark themes
const toggleTheme = () => {
setDarkTheme(prevTheme => !prevTheme);
};
// Aggiorna il setLanguage per salvare nel localStorage
const handleLanguageChange = (newLanguage) => {
setLanguage(newLanguage);
localStorage.setItem('selectedLanguage', newLanguage);
};
// Aggiorna il setReasoning per salvare nel localStorage
const toggleReasoning = () => {
setReasoning(prev => {
const newValue = !prev;
localStorage.setItem('reasoningEnabled', JSON.stringify(newValue));
return newValue;
});
};
// Funzione per scorrere al fondo della chat
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
// Effect per far scorrere la chat quando cambia il contenuto dei messaggi
useEffect(() => {
scrollToBottom();
}, [messages]);
// Send message directly to FastAPI endpoint
const sendMessage = async () => {
if (!messageInput.trim()) return;
// Add user message to the chat
const newUserMessage = { role: 'user', content: messageInput };
setMessages(prevMessages => [...prevMessages, newUserMessage]);
// Create the complete message history for the API
const messageHistory = [...messages, newUserMessage];
// Show loading state
setIsLoading(true);
// Clear the input field
setMessageInput('');
try {
// Build URL - if apiBaseUrl is empty, this becomes a relative URL
const url = apiBaseUrl ? `${apiBaseUrl}/chat` : '/chat';
// Aggiungo log per vedere qual è la personalità inviata
console.log(`Sending message with personality: ${personality}`);
console.log(`Connecting to API URL: ${url}`);
// Aggiungi headers per CORS più espliciti
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Origin': window.location.origin
},
credentials: 'include', // Include cookies se necessario
body: JSON.stringify({
messages: messageHistory,
language: language,
temperature: 0.7,
reasoning: reasoning,
personality: personality, // Confermo che la personalità viene inviata
stream: true
}),
});
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
// Streaming mode (sempre attivo)
let currentResponseContent = '';
let isInThinkingMode = false;
let currentThinkingContent = '';
// Add a placeholder for the streaming response
setMessages(prevMessages => [
...prevMessages,
{
role: 'coach',
content: '',
streaming: true,
thinking: '',
isThinking: false
}
]);
const responseStream = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream', // Aggiunto header per specificamente accettare eventi SSE
},
body: JSON.stringify({
messages: messageHistory,
language: language,
temperature: 0.7,
reasoning: reasoning,
personality: personality, // Confermo che la personalità viene inviata
stream: true
}),
});
if (!responseStream.ok) {
throw new Error(`API Error: ${responseStream.statusText}`);
}
// Process the streaming response
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
// Helper function to update the last message
const updateLastMessage = (content, thinking = null, isThinking = null) => {
setMessages(prevMessages => {
const newMessages = [...prevMessages];
const lastIndex = newMessages.length - 1;
const lastMessage = {...newMessages[lastIndex]};
if (content !== null) {
lastMessage.content = content;
}
if (thinking !== null) {
lastMessage.thinking = thinking;
}
if (isThinking !== null) {
lastMessage.isThinking = isThinking;
}
newMessages[lastIndex] = lastMessage;
return newMessages;
});
};
// Process chunks of text
const processChunk = (text) => {
// Check for thinking tags
if (!isInThinkingMode && text.includes('<think>')) {
isInThinkingMode = true;
const parts = text.split('<think>');
// Add any text before the <think> tag to the regular response
if (parts[0].length > 0) {
currentResponseContent += parts[0];
}
// Add the text after <think> to thinking content
currentThinkingContent = parts[1] || '';
// Update the message with thinking mode enabled
updateLastMessage(currentResponseContent, currentThinkingContent, true);
return;
}
// Check for end of thinking
if (isInThinkingMode && text.includes('</think>')) {
isInThinkingMode = false;
const parts = text.split('</think>');
// Add any remaining text to thinking
currentThinkingContent += parts[0];
// Add any text after </think> to the response
if (parts[1] && parts[1].length > 0) {
currentResponseContent += parts[1];
}
// Update the message
updateLastMessage(currentResponseContent, currentThinkingContent, true);
return;
}
// If we're in thinking mode, add to thinking content
if (isInThinkingMode) {
currentThinkingContent += text;
updateLastMessage(null, currentThinkingContent, true);
} else {
// Otherwise add to normal content
currentResponseContent += text;
updateLastMessage(currentResponseContent);
}
};
const parser = createParser((event) => {
if (event.type === "event") {
try {
const data = JSON.parse(event.data);
if (data.content) {
// Process the chunk for thinking tags
processChunk(data.content);
}
if (data.done) {
// Streaming completed, remove streaming flag but preserve thinking state
setMessages(prevMessages => {
const newMessages = [...prevMessages];
const lastIndex = newMessages.length - 1;
newMessages[lastIndex] = {
...newMessages[lastIndex],
streaming: false
// Manteniamo i campi thinking e isThinking esistenti
};
return newMessages;
});
setIsLoading(false);
}
} catch (e) {
console.error("Error parsing SSE data", e, event.data);
}
}
});
// Read the stream
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
parser.feed(text);
}
} catch (error) {
console.error('Failed to send message:', error);
// Aggiungi un messaggio più descrittivo sull'errore CORS
let errorMessage = 'Error: Could not connect to the server. Please try again later.';
if (error.message && error.message.includes('NetworkError')) {
errorMessage = 'Error: Network error occurred. This might be due to a CORS issue or the backend server being unavailable. Please contact the administrator.';
}
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: errorMessage }
]);
setIsLoading(false);
}
};
// Helper function for SSE parsing - migliorato per gestire più tipi di eventi SSE
function createParser(onParse) {
let buffer = '';
return {
feed(chunk) {
buffer += chunk;
// Cerca per pattern di fine del messaggio SSE (doppio newline)
let delimiterIndex;
while ((delimiterIndex = buffer.indexOf('\n\n')) !== -1) {
const rawEvent = buffer.slice(0, delimiterIndex);
buffer = buffer.slice(delimiterIndex + 2);
// Estrai i dati dall'evento
const dataMatch = /^data: (.+)$/m.exec(rawEvent);
if (dataMatch) {
try {
const jsonData = dataMatch[1];
onParse({ type: "event", data: jsonData });
} catch (e) {
console.error("Error parsing SSE event:", e, rawEvent);
}
}
}
}
};
}
// Add this function to trigger file selection
const triggerFileSelect = () => {
fileInputRef.current.click();
};
// File upload functionality - modified to add chat messages instead of alerts
const uploadFile = async (files) => {
if (!files.length) return;
// Add validation for PDF files only
for (let i = 0; i < files.length; i++) {
if (files[i].type !== 'application/pdf') {
// Add error message to chat instead of alert
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: 'Error: Only PDF files are accepted. Please try again with PDF documents only.' }
]);
return;
}
}
// Create list of file names for success message
const fileNames = Array.from(files).map(file => file.name).join(', ');
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
try {
// Build URL - if apiBaseUrl is empty, this becomes a relative URL
const url = apiBaseUrl ? `${apiBaseUrl}/upload` : '/upload';
// Show loading state
setIsLoading(true);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (response.ok) {
// Add success message to chat instead of alert
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: `Files uploaded successfully: ${fileNames}` }
]);
} else {
console.error('Upload failed:', response.statusText);
// Add error message to chat instead of alert
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: `Error: Failed to upload files. ${response.statusText}` }
]);
}
} catch (error) {
console.error('Failed to upload files:', error);
// Add error message to chat instead of alert
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: 'Error: Could not connect to the server. Failed to upload files.' }
]);
} finally {
setIsLoading(false);
}
};
// Add function to handle deleting all documents
const handleDeleteAllDocs = async () => {
if (window.confirm('Are you sure you want to delete all documents? This action cannot be undone.')) {
setIsDeleting(true);
try {
const url = apiBaseUrl ? `${apiBaseUrl}/docs` : '/docs';
const response = await fetch(url, {
method: 'DELETE',
});
if (response.ok) {
// Add confirmation message to chat
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: 'Memory has been cleared. All documents have been deleted from the database.' }
]);
} else {
console.error('Delete failed:', response.statusText);
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: `Error: Failed to delete documents. ${response.statusText}` }
]);
}
} catch (error) {
console.error('Failed to delete documents:', error);
setMessages(prevMessages => [...prevMessages,
{ role: 'coach', content: 'Error: Could not connect to the server. Failed to delete documents.' }
]);
} finally {
setIsDeleting(false);
}
}
};
// Add function to clear chat history
const clearChatHistory = () => {
setMessages([]);
localStorage.removeItem('chatMessages');
};
// Aggiorna la personalità ciclando tra le opzioni disponibili
const togglePersonality = () => {
setPersonality(prevPersonality => {
// Cicla tra le tre personalità
let newPersonality;
switch (prevPersonality) {
case 'cool':
newPersonality = 'cynical';
break;
case 'cynical':
newPersonality = 'supportive';
break;
default: // 'supportive' o qualsiasi altro valore
newPersonality = 'cool';
}
localStorage.setItem('personalityType', newPersonality);
return newPersonality;
});
};
// Funzione per ottenere l'icona della personalità corrente
const getCurrentPersonalityIcon = () => {
switch (personality) {
case 'cool': return <CoolIcon />;
case 'cynical': return <CynicalIcon />;
case 'supportive': return <SupportiveIcon />;
default: return <PersonalityIcon />;
}
};
// Funzione per ottenere il titolo della personalità
const getPersonalityTitle = () => {
switch (personality) {
case 'cool': return "Cool & Confident style";
case 'cynical': return "Cynical & Direct style";
case 'supportive': return "Supportive & Empathetic style";
default: return "Select AI personality style";
}
};
return (
<div className="App">
<header className="App-header">
<div className="logo-container">
<img
src={darkTheme ? "/chad-logo-white.svg" : "/chad-logo-white-bg-black.svg"}
alt="AI Coach Logo"
className="app-logo"
/>
<h1>MediChaiD - ChaD GPT (Conversational Health Assistance & Direction) </h1>
</div>
<div className="header-controls">
<button
className="theme-toggle"
onClick={toggleTheme}
title={darkTheme ? "Switch to light theme" : "Switch to dark theme"}
>
{darkTheme ? <SunIcon/> : <MoonIcon/>}
</button>
</div>
</header>
<div className="chat-container">
<div className="messages-container">
{messages.map((msg, index) => {
// Process content for both streaming and complete messages
let processedContent;
if (msg.role === 'coach') {
if (msg.isThinking) {
// Per i messaggi con stato thinking già elaborato durante lo streaming
processedContent = {
hasThinking: true,
thinking: msg.thinking || '',
response: msg.content
};
} else {
// Per i messaggi normali o quelli completati senza thinking
processedContent = processThinkingContent(msg.content);
}
} else {
// Per i messaggi utente
processedContent = {
hasThinking: false,
response: msg.content
};
}
return (
<div key={index} className={`message ${msg.role}`}>
{/* Render thinking box if present */}
{processedContent.hasThinking && (
<div className="thinking-content">
<div className="thinking-header">Reasoning Process:</div>
<div className="thinking-body">
{formatMessage(processedContent.thinking)}
</div>
</div>
)}
{/* Always render the main response */}
<div className={`message-content ${msg.streaming ? 'streaming' : ''}`}>
{/* Mostra i puntini di caricamento quando è l'ultimo messaggio, è del coach, è vuoto e isLoading è true */}
{msg.streaming && msg.content === '' && index === messages.length - 1 ? (
<div className="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
) : (
formatMessage(processedContent.response)
)}
</div>
</div>
);
})}
{/* Aggiungiamo un elemento invisibile per lo scroll */}
<div ref={messagesEndRef} />
</div>
<div className="input-container">
{/* Primary container for input and send button */}
<div className="primary-input-container">
<input
type="text"
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && messageInput.trim() && !isLoading && sendMessage()}
placeholder="Type your message..."
className="message-input"
disabled={isLoading}
/>
<button
onClick={sendMessage}
className={`send-button ${messageInput.trim() ? 'enabled' : 'disabled'} ${isLoading ? 'loading' : ''}`}
disabled={!messageInput.trim() || isLoading}
aria-label="Send message"
>
{isLoading ? <LoadingIcon /> : <SendIcon />}
</button>
</div>
{/* Secondary container for all other buttons */}
<div className="secondary-buttons-container">
{/* Left button group */}
<div className="left-buttons-group">
<div className="language-select-container">
<select
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="language-selector"
>
<option value="auto">{languageLabels.auto}</option>
<option value="english">{languageLabels.english}</option>
<option value="french">{languageLabels.french}</option>
<option value="italian">{languageLabels.italian}</option>
<option value="german">{languageLabels.german}</option>
</select>
</div>
<button
className={`personality-button ${personality}`}
onClick={togglePersonality}
aria-label={`AI personality: ${personality}`}
title={getPersonalityTitle()}
>
{getCurrentPersonalityIcon()}
</button>
<button
onClick={toggleReasoning}
className={`thinking-button ${reasoning ? 'active' : ''}`}
title={reasoning ? "Disable thinking mode" : "Enable thinking mode"}
aria-label={reasoning ? "Disable thinking mode" : "Enable thinking mode"}
>
{reasoning ? <ThinkingIcon /> : <ThinkingDisabledIcon />}
</button>
</div>
{/* Right button group */}
<div className="right-buttons-group">
<button
onClick={clearChatHistory}
className="clear-history-button"
disabled={isLoading || isDeleting || messages.length === 0}
aria-label="Clear chat history"
title="Clear chat history"
>
<ClearHistoryIcon />
</button>
<button
onClick={triggerFileSelect}
className="attachment-button"
disabled={isLoading || isDeleting}
aria-label="Upload PDF files"
title="Upload PDF files"
>
<AttachmentIcon />
</button>
<button
onClick={handleDeleteAllDocs}
className="delete-button"
disabled={isLoading || isDeleting}
aria-label="Delete all documents"
title="Delete all documents"
>
{isDeleting ? <LoadingIcon /> : <TrashIcon />}
</button>
</div>
</div>
</div>
<div className="file-upload">
<input
ref={fileInputRef}
type="file"
multiple
accept="application/pdf"
onChange={(e) => uploadFile(e.target.files)}
className="file-input hidden"
disabled={isLoading}
style={{ display: 'none' }} // Hide the input
/>
</div>
</div>
</div>
);
}
export default App;

View file

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View file

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View file

@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
reportWebVitals();

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View file

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';