Compare commits

...
72 Commits
Author SHA1 Message Date
xavier 364c4cf37f MAJ README partie donnateur
Build and Push Docker Image / build-and-push (push) Successful in 39s
2026-09-05 11:42:34 +02:00
xavier 7cd6e1db6e Amélioration modules updater et sélection des IA suivant disponibilités
Build and Push Docker Image / build-and-push (push) Successful in 7m31s
2026-09-05 06:52:42 +02:00
xavier d36cb07ab1 Amélioration modules updater et sélection des IA suivant disponibilités
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-29 12:40:56 +02:00
xavier 3553809df3 Amélioration modules updater
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-29 11:55:10 +02:00
xavier b521999162 Améliorations BO4
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-29 11:22:20 +02:00
xavier 57db888e89 Améliorations BO3
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-29 11:18:17 +02:00
xavier e8c2bc4418 Améliorations BO2
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-29 11:11:24 +02:00
xavier 6a4cc97356 Améliorations BO
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-29 11:03:29 +02:00
xavier 21348c70aa des modifications spécifiques sur les droits des utilisateurs
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-29 10:35:26 +02:00
xavier e82a23988b des modifications spécifiques sur les droits des utilisateurs
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-29 10:24:17 +02:00
xavier b5926633a3 on test
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-29 10:05:48 +02:00
xavier 8cd913d5b3 refonte
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-29 09:52:32 +02:00
xavier 1eb619a50b refonte
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-08-29 09:41:27 +02:00
xavier 1424cb7ab4 MAJ pop-up
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-29 08:58:20 +02:00
xavier 7ef450da91 Mise en place gestion financiére
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-29 08:30:53 +02:00
xavier 5d4ac62b87 Correction isertion module financier
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-29 07:57:50 +02:00
xavier b96d402ae9 Isertion module financier
Build and Push Docker Image / build-and-push (push) Successful in 2m35s
2026-08-29 07:50:32 +02:00
xavier a2261ab8dd MAJ Phase 10 : Support KubeJS, bouton RAG UI et Modale d'upload admin
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-29 07:38:55 +02:00
xavier f17ef384f6 Correction fonction RAG auto incrémentation
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-28 20:29:06 +02:00
xavier 3b2cf51a24 Amélioration fonction RAG auto incrémentation
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-28 20:13:17 +02:00
xavier b88d693d69 Amélioration fonction RAG
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-28 19:48:04 +02:00
xavier e3557873b9 Passage à FAISS et Ollama pour le RAG
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-28 19:23:22 +02:00
xavier f6737776d4 Suppression de ChromaDB (Crash CPU) et passage à FAISS
Build and Push Docker Image / build-and-push (push) Successful in 2m35s
2026-08-28 19:13:03 +02:00
xavier f164da9a69 Fix CI build: passage à Python 3.11-slim
Build and Push Docker Image / build-and-push (push) Successful in 4m42s
2026-08-28 18:45:58 +02:00
xavier 7ae43f7b0b Mise a jour module éduction nationale
Build and Push Docker Image / build-and-push (push) Failing after 48s
2026-08-28 18:35:30 +02:00
xavier fbc38fbb19 Mise a jour module éduction nationale
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-27 22:06:37 +02:00
xavier 9496ed6fb7 Mise a jour module éduction nationale
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-27 22:03:41 +02:00
xavier dd5cd760e0 Mise a jour module éduction nationale
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-27 22:00:23 +02:00
xavier 06c74ee4a5 Mise a jour module éduction nationale
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-27 21:53:28 +02:00
xavier 6be6a71264 Mise a jour media: suppression de la gestion dynamique HF et securisation payant OpenRouter
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-27 21:01:37 +02:00
xavier a6d09f9baf Mise a jour media: suppression de la gestion dynamique HF et securisation payant OpenRouter
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-27 20:49:29 +02:00
xavier c2c35542d7 Mise a jour media: gestion dynamique HF et securisation payant OpenRouter
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-27 20:37:05 +02:00
xavier aa0190d218 Contournement de la panne DNS Hugging Face via le nouveau routeur
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-08-27 20:21:57 +02:00
xavier 909a4efb11 Contournement de la panne DNS Hugging Face via le nouveau routeur
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-27 20:15:31 +02:00
xavier 073c779109 Modification openrouter image version payante
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-27 19:32:08 +02:00
xavier eab18d8324 Ajout du connecteur Hugging Face et mise à jour du Multi-IA
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-27 19:23:04 +02:00
xavier a510916247 MAJ MAIN.PY POUR IMAGE
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-27 18:09:45 +02:00
xavier 6d509edbb9 Fix route Media FastHTML native
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-26 20:13:21 +02:00
xavier d6db532a97 Route Media native Starlette path_params
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-26 20:06:52 +02:00
xavier 7a0f580fa8 Utilisation de @rt pour la route Media
Build and Push Docker Image / build-and-push (push) Successful in 37s
2026-08-26 20:02:22 +02:00
xavier 937e877ee2 Correction finale route Media FastHTML
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-26 19:57:30 +02:00
xavier 26bc8483c6 Route Media native Starlette
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-26 19:50:36 +02:00
xavier 76b9964c14 Passage definitif en RAM pour les images
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-26 19:32:29 +02:00
xavier cbe8d8ec6d Retour au StaticFiles officiel
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-26 19:29:07 +02:00
xavier 0a73ef3bab Correction route media lecture RAM brute
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-26 19:21:09 +02:00
xavier 61ab2d2d96 tentative réparation erreur 404
Build and Push Docker Image / build-and-push (push) Successful in 36s
2026-08-26 18:38:22 +02:00
xavier 4a6c03e338 tentative erreur 404
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-26 18:24:29 +02:00
xavier 2b80986785 Déploiement serveur pour test de génération multimédia (HuggingFace + Cloudflare)
Build and Push Docker Image / build-and-push (push) Successful in 3m20s
2026-08-26 18:11:36 +02:00
xavier 177f397163 main.py fonction get
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-24 22:08:22 +02:00
xavier 85a6fe1d27 Déploiement serveur pour test de génération multimédia (HuggingFace + Cloudflare)
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-24 21:14:17 +02:00
xavier 916af3e62b Déploiement serveur pour test de génération multimédia (HuggingFace + Cloudflare)
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-24 20:44:15 +02:00
xavier 391d9acae1 Déploiement serveur pour test de génération multimédia (HuggingFace + Cloudflare)
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-24 19:44:44 +02:00
xavier 519a42107f Ajout du module multimédia (Text-to-Image via HuggingFace et Cloudflare) et sécurisation du routeur Multi-IA
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-08-24 19:25:05 +02:00
xavier c82e12f49a Mise à jour du RAG : Ajout d'une catégorie générale pour la sauvegarde systématique
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-08-23 18:03:35 +02:00
xavier 9a454dd15e Activation de l'injection RAG et mise à jour de la base de connaissances
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-23 15:38:26 +02:00
xavier 9fdb2bd73e Amélioration contraste mobile, traçabilité des IA et filtrage catalogue
Build and Push Docker Image / build-and-push (push) Successful in 35s
2026-08-23 15:09:57 +02:00
xavier 3e140d7d6f Mise en veille PayPal, synchro solde OpenRouter et modale de rejet financier
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-23 11:38:37 +02:00
xavier 2ea18701b5 Test : Intégration bouton PayPal Sandbox et activation du Serveur Mail
Build and Push Docker Image / build-and-push (push) Successful in 1m45s
2026-08-23 09:47:14 +02:00
xavier 632c23069a MAJ Globale : Chemins absolus (NAS), Model Updater auto, Timeout 90s OpenRouter, Pop-up et traçabilité financière
Build and Push Docker Image / build-and-push (push) Successful in 1m48s
2026-08-22 03:08:57 +02:00
xavier b4ed2947d4 Auto-updater, limites CPU 60% et facturation corrigés
Build and Push Docker Image / build-and-push (push) Successful in 1m55s
2026-08-22 01:10:40 +02:00
xavier 71d6f51ee0 feat: Authentification sécurisée fonctionnelle et correction du hash bcrypt
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-20 21:59:30 +02:00
xavier 2d7d93bc27 fix: Résolution import RAG et correction HTTPResponse pour les pings
Build and Push Docker Image / build-and-push (push) Successful in 38s
2026-08-20 19:33:17 +02:00
xavier ffe820b00f chore: Serveur 100% stable, ports corrigés, prêt pour le RAG
Build and Push Docker Image / build-and-push (push) Successful in 1m50s
2026-08-20 15:24:28 +02:00
xavier 7125842853 docs: Anonymisation complète du README, paramétrage de la modération et sécurisation
Build and Push Docker Image / build-and-push (push) Successful in 34s
2026-08-19 10:38:19 +02:00
xavier a4490944f1 fix: Integration officielle du widget de don PayPal utilisateur et stabilisation
Build and Push Docker Image / build-and-push (push) Successful in 1m48s
2026-08-19 03:14:24 +02:00
xavier a10c854b2b fix: Migration vers le nouveau SDK Google GenAI et mise à jour des dépendances
Build and Push Docker Image / build-and-push (push) Successful in 2m4s
2026-08-19 02:56:25 +02:00
xavier 5a4e7dd094 feat: Whitelist VPN pour les réseaux locaux de supervision et passe-droit VIP
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-08-19 00:44:24 +02:00
xavier 34163a630c fix: Résolution de l'import circulaire, ajout des boutons UI et étiquetage Gitea
Build and Push Docker Image / build-and-push (push) Successful in 33s
2026-08-19 00:18:42 +02:00
xavier b20ce7e903 chore: Test du nouveau jeton d'accès aux paquets (PAT)
Build and Push Docker Image / build-and-push (push) Successful in 7m16s
2026-08-18 23:28:22 +02:00
xavier 88c50b8a8d fix: Renommage des secrets pour contourner la restriction Gitea
Build and Push Docker Image / build-and-push (push) Failing after 17s
2026-08-18 23:04:44 +02:00
xavier 268e6f1b06 chore: Troisième tentative d'enregistrement du runner
Build and Push Docker Image / build-and-push (push) Failing after 18s
2026-08-18 22:51:02 +02:00
xavier 099687b1a3 chore: Relance du workflow Gitea Actions après configuration du réseau et des secrets
Build and Push Docker Image / build-and-push (push) Failing after 4m33s
2026-08-18 22:34:06 +02:00
51 changed files with 16765 additions and 6209 deletions

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
ESC-j * Forward one file line (or _N file lines).
ESC-k * Backward one file line (or _N file lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
ESC-b * Backward one window, but don't stop at beginning-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
ESC-f Like F but ring the bell when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
Search is case-sensitive unless changed with -i or -I.
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< HOME * Go to first line in file (or line _N).
G > ESC-> END * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> * Mark the top line (or line _N) with <letter>.
M_<_l_e_t_t_e_r_> * Mark the bottom line (or line _N) with <letter>.
'_<_l_e_t_t_e_r_> * Go to a previously marked position (put on position _N).
'' * Go to the previous position.
^X^X * Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after full screen movement.
-W ........ --HILITE-UNREAD
Highlight first new line after any movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--autosave=[_m_/_!_*]
Actions which cause the history file to be saved.
--emouse=[_v_s_c_r_o_l_l,_v_d_r_a_g,_h_s_c_r_o_l_l,_h_d_r_a_g,_l_c_l_i_c_k,_r_c_l_i_c_k]
Enable mouse features.
--end-prompt=[_s_t_r_i_n_g]
String to be printed after erasing the prompt.
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--form-feed
Stop scrolling when a form feed character is reached.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--hilite-target
Highlight the target line.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse clicking and vertical scrolling.
--no-edit-warn
Don't warn when using v command on a file opened via LESSOPEN.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-paste
Ignore pasted input.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--past-eof
Scrolling commands continue past end of file.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=[_N]
Each click of the mouse wheel moves _N lines.
--wordwrap
Wrap lines at spaces.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
+3 -2
View File
@@ -13,8 +13,9 @@ jobs:
uses: docker/login-action@v2
with:
registry: gitea.aethas38.duckdns.org
username: ${{ secrets.GITEA_USERNAME }}
password: ${{ secrets.GITEA_TOKEN }}
# On utilise ici les nouveaux noms autorisés
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
+2 -1
View File
@@ -8,4 +8,5 @@ Echange GEMINY.txt
aethas_logs.txt
Important/
ARCHIVAGE ECHANGE GEMINY.txt
docker-compose.yml
test financier/
SAVE/
+329
View File
@@ -0,0 +1,329 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
ESC-j * Forward one file line (or _N file lines).
ESC-k * Backward one file line (or _N file lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
ESC-b * Backward one window, but don't stop at beginning-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
ESC-f Like F but ring the bell when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
Search is case-sensitive unless changed with -i or -I.
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< HOME * Go to first line in file (or line _N).
G > ESC-> END * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> * Mark the top line (or line _N) with <letter>.
M_<_l_e_t_t_e_r_> * Mark the bottom line (or line _N) with <letter>.
'_<_l_e_t_t_e_r_> * Go to a previously marked position (put on position _N).
'' * Go to the previous position.
^X^X * Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after full screen movement.
-W ........ --HILITE-UNREAD
Highlight first new line after any movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--autosave=[_m_/_!_*]
Actions which cause the history file to be saved.
--emouse=[_v_s_c_r_o_l_l,_v_d_r_a_g,_h_s_c_r_o_l_l,_h_d_r_a_g,_l_c_l_i_c_k,_r_c_l_i_c_k]
Enable mouse features.
--end-prompt=[_s_t_r_i_n_g]
String to be printed after erasing the prompt.
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--form-feed
Stop scrolling when a form feed character is reached.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--hilite-target
Highlight the target line.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse clicking and vertical scrolling.
--no-edit-warn
Don't warn when using v command on a file opened via LESSOPEN.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-paste
Ignore pasted input.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--past-eof
Scrolling commands continue past end of file.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=[_N]
Each click of the mouse wheel moves _N lines.
--wordwrap
Wrap lines at spaces.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,7 +1,10 @@
FROM python:3.13-slim
FROM python:3.11-slim
WORKDIR /app
# Ligne ajoutée pour lier l'image au dépôt Gitea automatiquement
LABEL org.opencontainers.image.source="https://gitea.aethas38.duckdns.org/xavier/aethas-free"
# Installation des dépendances système nécessaires pour certaines librairies Python
RUN apt-get update && apt-get install -y gcc default-libmysqlclient-dev && rm -rf /var/lib/apt/lists/*
+69 -40
View File
@@ -1,59 +1,88 @@
# Multi-IA Orchestrateur
# Aethas38 - Multi-IA Orchestrateur (Minecraft-Tech Edition)
Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécurisé et facturable.
Plateforme SaaS d'IA modulaire : Orchestrateur local, routeur intelligent, sécurisé et facturable[cite: 13].
<!-- MAJ 23/08/2026 : URL de production masquée pour raisons de cybersécurité (Information Disclosure) -->
**Architecture réseau :** Déploiement via Docker derrière Nginx Proxy Manager (NPM) sur `https://ia.votre-domaine.com`[cite: 13]. Timeout proxy ajusté à 300s pour le calcul lourd[cite: 13].
## 📜 Licence & Droits d'Utilisation (EULA)
Ce projet est distribué sous une licence "Source Available" propriétaire (EULA).
* **Modifications & Partage :** Les utilisateurs sont autorisés à modifier et améliorer le code, à condition de partager ces améliorations avec la communauté.
* **Restrictions strictes :** Il est **strictement interdit** de modifier ou supprimer les crédits de l'auteur originel, de retirer le module de dons dédié au créateur, ou de désactiver la télémétrie de sécurité à l'installation. L'installation requiert l'acceptation explicite de ces termes.
* **Donations obligatoires :** Le système intègre nativement deux modules de dons. L'un est modifiable par l'hébergeur du projet. L'autre (marqué "Soutenir le créateur de ce projet") est inaltérable et relié directement au créateur originel. Le retrait de ce module est une violation stricte de cette licence.
Ce projet est distribué sous une licence "Source Available" propriétaire (EULA) rédigée par Xavier Fernandez (Aethas38)[cite: 13].
* **Modifications & Partage :** Les utilisateurs sont autorisés à modifier et améliorer le code, à condition de partager ces améliorations avec la communauté[cite: 13].
* **Restrictions strictes :** Il est **strictement interdit** de modifier ou supprimer les crédits de l'auteur originel, de retirer le module de dons dédié au créateur, ou de désactiver la télémétrie de sécurité[cite: 13]. L'installation requiert l'acceptation explicite de ces termes[cite: 13].
* **Donations obligatoires :** Le système intègre nativement deux modules de dons[cite: 13]. L'un est modifiable par l'hébergeur du projet[cite: 13]. L'autre (marqué "Soutenir le créateur de ce projet") est inaltérable et relié directement au créateur originel[cite: 13].
## ⚠️ Pré-requis Matériels (Important)
Pour fonctionner de manière optimale, l'Orchestrateur Local nécessite au minimum **une carte graphique avec 4 Go de VRAM dédiée**. Il est possible de configurer l'application pour utiliser le CPU, mais les performances seront considérablement réduites et les temps de réponse allongés. **L'auteur décline toute responsabilité en cas de surcharge matérielle liée à une utilisation CPU intensive.**
Pour fonctionner de manière optimale, l'Orchestrateur Local nécessite au minimum **une carte graphique avec 4 Go de VRAM dédiée** (ex: GTX 1650)[cite: 13]. Il est possible de configurer l'application pour utiliser le CPU, mais les performances seront considérablement réduites[cite: 13]. **L'auteur décline toute responsabilité en cas de surcharge matérielle liée à une utilisation CPU intensive[cite: 13].**
## 📋 To-Do List (Roadmap)
### Phase 1 & 2 : Infrastructure & Backend de Base ✅
- [x] Installation Docker, Pare-feu
- [x] Serveur Local Ollama + FastHTML
- [x] Routeur Multi-IA (OpenRouter, Gemini, Local) avec système de cascade
- [x] Continuité de service : IA de secours automatique
- [x] Installation Docker, Nginx (NPM), Pare-feu, Gitea/GitHub[cite: 13]
- [x] Serveur Local Ollama (Abliterated 3B) + FastHTML[cite: 13]
- [x] Routeur Multi-IA (OpenRouter, Gemini, Local) avec système de cascade et directives d'aiguillage strictes[cite: 13]
- [x] Continuité de service : IA de secours automatique et Timeout à 300s[cite: 13].
### Phase 3 : Refonte UI & Expérience Utilisateur ✅
- [x] Thème "Minecraft-Tech" (Inspiré serveurs/redstone) compatible Mobile (Responsive)
- [x] Historique (Sidebar) avec Renommage, Épinglage, Suppression et Navigation isolée
- [x] Zone de saisie intelligente (Drag & Drop, multi-fichiers)
- [x] Export modulaire (PDF, DOC, TXT, JSON, ZIP)
- [x] Architecture modulaire propre (Dossier `modules/`)
- [x] Thème "Minecraft-Tech" (Inspiré serveurs/redstone) compatible Mobile (Responsive / Contraste OLED élevé)[cite: 13]
- [x] Historique (Sidebar) avec Renommage, Épinglage, Suppression et Navigation isolée par session[cite: 13]
- [x] Zone de saisie intelligente (Drag & Drop, Collage multi-fichiers)[cite: 13]
- [x] Export modulaire et propre (PDF, DOC, TXT, JSON, ZIP)[cite: 13]
- [x] Boutons de partage direct (WhatsApp, Email) et Déconnexion[cite: 13].
- [x] Indicateur d'avancement dynamique (Loader Néon) pendant le traitement des IA[cite: 13].
- [x] Transparence du Routage : Affichage visuel (Badges) du parcours multi-IA (Succès, Échecs, Erreurs 404/503) pour chaque requête[cite: 13].
### Phase 4 : Gestion des Utilisateurs & Sécurité Légale
- [ ] Mettre en place une bibliothèque logicielle autonome sur le serveur.
- [ ] Routage automatique (Gratuit vs Payant) sélectionné par l'utilisateur.
- [ ] Système de connexion sécurisé avec configuration du premier lancement.
- [x] Système de cryptage avancé (Fernet) et de Hashing (Bcrypt) mis en place.
- [x] Sécurité : Protection Bruteforce. **Blocage total des accès VPN/Proxy**.
- [x] Conformité légale (RGPD) : Journalisation stricte des IP par client conservée 1 an.
- [ ] Multilinguisme : Adaptation automatique de la langue du site via IP.
- [ ] Mettre en place une bibliothèque logicielle autonome sur le serveur (création PDF, DOCX, etc.)[cite: 13].
- [x] Routage automatique (Gratuit vs Payant) sélectionné par l'utilisateur avec solution de repli[cite: 13].
- [x] Système de cryptage de grade militaire (Fernet) et de Hashing (Bcrypt) mis en place[cite: 13].
- [x] Sécurité Légère : Fail2Ban, protection Bruteforce et DDoS[cite: 13]. **Blocage total des accès VPN**[cite: 13]. Ping silencieux[cite: 13].
- [x] Conformité légale (LCEN/RGPD) : Création de dossiers utilisateurs isolés[cite: 13]. Journalisation stricte des IP et de l'activité conservée 365 jours[cite: 13]. Purge automatique des comptes inactifs > 1 an[cite: 13].
- [ ] Multilinguisme : Adaptation automatique de la langue du site via la géolocalisation de l'IP[cite: 13].
- [ ] Cadre Légal (SaaS) : Rédaction ASAP des CGV (crédits non-remboursables, facturation de tokens, gestion des litiges) et CGU (interdiction de contenus illicites, logs IP conservés 1 an, politiques de confidentialité Google/OpenRouter).
- [ ] Notifications SMTP : Intégration de `debug_mailer.py` via un service transactionnel (Brevo/SendGrid) pour l'envoi automatisé des CGU/CGV, confirmations d'inscription, alertes de quotas RAG et reçus.
- [ ] Audit de Sécurité Global : Isolation stricte des historiques utilisateurs dans des fichiers JSON distincts, sécurisation `.gitignore` des clés (API/SMTP), vérification de l'étanchéité web des volumes `/DATA/AppData/`, et tests anti-injection de prompts sur le Superviseur IA.
### Phase 5 : Sécurité Financière & Facturation 💰
- [x] Pré-calcul local du nombre de tokens (via `tiktoken`) pour estimer le coût avant envoi.
- [ ] Interface UI : Affichage du Pop-Up de validation du coût.
- [ ] Cryptage absolu des données sensibles bancaires.
- [ ] Module de conversion des devises en temps réel.
- [x] **Gestionnaire Financier :** Majoration automatique appliquée selon les statuts pour générer la facturation finale.
- [ ] Export comptable Excel (TCD) avec archivage PDF automatisé (Admin uniquement).
### Phase 5 : Sécurité Financière & Facturation Exacte 💰
- [x] Pré-calcul local du nombre de tokens (via `tiktoken`) pour estimer le coût avant envoi[cite: 13].
- [x] Catalogue tarifaire dynamique (mise à jour auto) supprimant les coûts écrits en dur[cite: 13].
- [x] Affichage du Pop-Up OBLIGATOIRE de validation du coût pour TOUS les utilisateurs[cite: 13].
- [x] Télémétrie Admin : Suivi en temps réel de la consommation (Jour/Mois) et du solde restant sur l'API distante[cite: 13].
- [x] Module de conversion des devises en temps réel (API Frankfurter) pour un affichage transparent.
- [x] **Utilisateurs Privilégiés (Zéro Marge) :** Application de tarifs coûtants pour les rôles Super Admin (Xavier), Admin (Céline) et Famille (Audric, Renan, Anaëlle). Marge appliquée pour VIP (7.5%) et Standard (15%).
- [x] Export comptable Excel (TCD) par utilisateur via Pandas/Openpyxl (Optimisé pour les déclarations URSSAF)[cite: 13].
- [ ] Subventions & Accompagnement : Lancement des dossiers de financement d'infrastructure auprès de la CCI Nord Isère (Pass Création), BPI France (Bourse French Tech jusqu'à 30k€), et demande d'ACRE à l'URSSAF.
### Phase 6 : Nouvelles Capacités IA (Spécialisations & Modèles) 🤖
*L'infrastructure bascule sur un catalogue dynamique mis à jour toutes les 2h pour pallier les décommissionnements[cite: 13].*
*Filtrage actif des modèles OpenRouter restreints (ex: Politiques de confidentialité strictes ignorées)[cite: 13].*
| Domaine de tâche | Moteur | Rôle principal |
| :--- | :--- | :--- |
| **Orchestration & Tri** | Local (Ollama) | Analyse l'intention, gère le chat rapide et route vers la bonne IA |
| **Bureautique & Code** | OpenRouter API | Programmation, scripts, macros VBA, mathématiques |
| **Images HD / Fichiers** | Google API | Analyse d'images/vidéos et documents joints |
| **Génération Multimédia** | HuggingFace / Fal.ai | Création d'images et vidéos (Text-to-Image / Text-to-Video) |
| Domaine de tâche | Modèle / Moteur | Fournisseur / Hébergement | Rôle principal |
| :--- | :--- | :--- | :--- |
| **Orchestration & Tri** | Llama 3.2 3B Instruct Abliterated | Local (GTX 1650 4Go) | Analyse l'intention, juge de sécurité sans censure et route vers la bonne IA[cite: 13] |
| **Bureautique & Code** | Catalogue Dynamique (ex: Sonnet 3.5) | OpenRouter | Programmation KubeJS, scripts, mathématiques[cite: 13] |
| **Images HD / Fichiers** | Gemini / Vision | Google API | Analyse d'images/vidéos et documents joints[cite: 13] |
### Phase 7 & 8 : Maintenance & Déploiement
- [ ] Création d'un Wiki d'installation détaillé multilingue.
- [x] Fichiers `docker-compose.yml` et `Dockerfile` préparés pour le déploiement conteneurisé.
- [x] Configuration du Webhook et script Bash de déploiement automatique (`git pull` + Docker Compose Rebuild) ciblant la stack `AETHAS38_MULTI_IA`.
- [x] Stratégie de mise à jour transparente via Gitea (dépôt public pour automatisation fluide sans authentification complexe).
- [x] Script de surveillance de l'espace disque du serveur avec sonde en direct et remontée d'alerte UI.
- [x] Sauvegarde automatique asynchrone : Compression du site toutes les 6 heures et export vers GDrive via `rclone` et CRON.
- [x] Hébergement Gitea Docker[cite: 13]. Injection des clés via `env_file`[cite: 13]. Volumes persistants configurés[cite: 13].
- [x] Persistance absolue et sécurité : Fichiers d'historique, logs, facturation et dossiers RAG isolés sur volume RAID mdadm externe[cite: 13].
- [x] Synchronisation temporelle : Conteneurs Docker synchronisés sur l'horloge système du serveur Debian hôte (NTP via montage en lecture seule)[cite: 13].
- [ ] Protection Intellectuelle : Création d'un fichier LICENSE strict (BSL/EULA) à la racine du dépôt pour bloquer l'usage commercial non autorisé.
- [ ] Déploiement du Wiki : Mise en place sur Gitea/MkDocs d'une documentation segmentée (Déploiement Administrateur, Architecture Technique, et Guide Utilisateur).
### Phase 9 : Modération Automatisée & Conformité Pénale 🚨
- [x] Système hiérarchique : `Super Admin`, `Admin`, `VIP` (Famille) et `Standard`[cite: 13]. Quotas de ressources (60% CPU/RAM) applicables selon le rang[cite: 13].
- [x] Modération préventive "Anti-Zèle" : Filtrage par IA Locale tolérant la fiction et les jeux vidéo[cite: 13].
- [x] Système d'Avertissements (Strikes) automatisé et preuve d'audit manuscrite[cite: 13].
### Phase 10 : Base de Connaissances Vectorielle (RAG FAISS) 📚
- [x] Initialisation de l'arborescence dynamique (`RAG/`) sur le stockage RAID[cite: 13].
- [x] Moteur Vectoriel Local : Remplacement de la lecture brute par une base **FAISS** haute performance[cite: 13].
- [x] Embeddings IA : Vectorisation mathématique via modèle local (Ollama / `nomic-embed-text`) pour une recherche sémantique ultra-rapide[cite: 13].
- [x] Ingestion Multi-Formats : Support étendu aux PDF institutionnels et aux fichiers de code/configuration (`.js`, `.yml`, `.json`, `.md`, `.txt`) pour la documentation de mods (ex: KubeJS)[cite: 13].
- [x] Superviseur IA (Arrière-plan) : Évaluation silencieuse de la pertinence technique des réponses avant indexation via Threading (zéro latence pour l'utilisateur)[cite: 13].
- [x] Forçage Manuel (UI) : Ajout d'un bouton HTMX "💾 Mémoriser" pour forcer l'apprentissage vectoriel à la volée[cite: 13].
- [x] Interface UI : Ajout d'une vue Administrateur pour téléverser de nouveaux documents RAG directement depuis le navigateur[cite: 13].
- [ ] Isolation des Quotas RAG : Segmentation des dossiers et index FAISS par utilisateur avec sonde de blocage d'upload fixée à 1 Go (Les rôles `Super Admin`, `Admin` et `Famille` sont exemptés de cette restriction).
- [ ] Extension de Stockage RAG (5€/mois) : Automatisation d'un CRON mensuel vérifiant les paiements Stripe/PayPal. En cas d'impayé, purge LIFO (Last In, First Out : suppression physique des fichiers les plus récents) pour repasser sous les 1 Go, suivie du nettoyage des vecteurs orphelins via ré-indexation complète.
- [ ] Instauration d'une page de configuration permettant de renseigner un serveur mail, les clés API et tout le nécessaire à l'utilisation. Dans le crowdfunding il a été annoncé qu'une IA local n'était pas obligatoire, il faudra donc prévoir de supprimer l'obligation d'IA local pour la partie des donnateurs et toute la gestion financiére excepté l'utilisation du montant restant sur les fournisseurs. Il faut donc créer une branche donnateur qui corresponde à la mienne mais sans la partie inutile.
+57
View File
@@ -0,0 +1,57 @@
[
{
"timestamp": 1787262333.1032357,
"date": "2026-08-20 23:45:33",
"utilisateur": "xavier",
"rang": "Super Admin",
"type_operation": "DEBIT_REQUETE",
"modele": "anthropic/claude-3.5-sonnet",
"tokens": 87,
"cout_brut_api": 0.00261,
"prix_facture_client": 0.00261
},
{
"timestamp": 1787262606.6284242,
"date": "2026-08-20 23:50:06",
"utilisateur": "xavier",
"rang": "Super Admin",
"type_operation": "DEBIT_REQUETE",
"modele": "anthropic/claude-3.5-sonnet",
"tokens": 88,
"cout_brut_api": 0.00132,
"prix_facture_client": 0.00132
},
{
"timestamp": 1787263101.5169146,
"date": "2026-08-20 23:58:21",
"utilisateur": "xavier",
"rang": "Super Admin",
"type_operation": "DEBIT_REQUETE",
"modele": "anthropic/claude-3.5-sonnet",
"tokens": 88,
"cout_brut_api": 0.00264,
"prix_facture_client": 0.00264
},
{
"timestamp": 1787344221.9884093,
"date": "2026-08-21 22:30:21",
"utilisateur": "xavier",
"rang": "Super Admin",
"type_operation": "DEBIT_REQUETE",
"modele": "anthropic/claude-3.5-sonnet",
"tokens": 17,
"cout_brut_api": 0.000255,
"prix_facture_client": 0.000255
},
{
"timestamp": 1787348873.0218458,
"date": "2026-08-21 23:47:53",
"utilisateur": "xavier",
"rang": "Super Admin",
"type_operation": "DEBIT_REQUETE",
"modele": "anthropic/claude-3.5-sonnet",
"tokens": 288,
"cout_brut_api": 0.00432,
"prix_facture_client": 0.00432
}
]
+11924
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,282 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
ESC-j * Forward one file line (or _N file lines).
ESC-k * Backward one file line (or _N file lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
ESC-b * Backward one window, but don't stop at beginning-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
ESC-f Like F but ring the bell when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
Search is case-sensitive unless changed with -i or -I.
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< HOME * Go to first line in file (or line _N).
G > ESC-> END * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> * Mark the top line (or line _N) with <letter>.
M_<_l_e_t_t_e_r_> * Mark the bottom line (or line _N) with <letter>.
'_<_l_e_t_t_e_r_> * Go to a previously marked position (put on position _N).
'' * Go to the previous position.
^X^X * Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after full screen movement.
-W ........ --HILITE-UNREAD
Highlight first new line after any movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--autosave=[_m_/_!_*]
Actions which cause the history file to be saved.
--emouse=[_v_s_c_r_o_l_l,_v_d_r_a_g,_h_s_c_r_o_l_l,_h_d_r_a_g,_l_c_l_i_c_k,_r_c_l_i_c_k]
Enable mouse features.
--end-prompt=[_s_t_r_i_n_g]
String to be printed after erasing the prompt.
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--form-feed
Stop scrolling when a form feed character is reached.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--hilite-target
Highlight the target line.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse clicking and vertical scrolling.
--no-edit-warn
Don't warn when using v command on a file opened via LESSOPEN.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-paste
Ignore pasted input.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
+14
View File
@@ -0,0 +1,14 @@
version: "3.8"
services:
aethas38_app:
container_name: aethas38_app
image: gitea.aethas38.duckdns.org/xavier/aethas-free:latest
restart: unless-stopped
ports:
# Remplacez VOTRE_PORT_EXTERNE par le port disponible sur votre serveur
- "VOTRE_PORT_EXTERNE:5001"
volumes:
# Modifiez le chemin gauche vers le dossier sécurisé de votre choix
- /chemin/vers/votre/stockage:/app/data
environment:
- TZ=Europe/Paris
+222
View File
@@ -0,0 +1,222 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
ESC-j * Forward one file line (or _N file lines).
ESC-k * Backward one file line (or _N file lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
ESC-b * Backward one window, but don't stop at beginning-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
ESC-f Like F but ring the bell when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
Search is case-sensitive unless changed with -i or -I.
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< HOME * Go to first line in file (or line _N).
G > ESC-> END * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> * Mark the top line (or line _N) with <letter>.
M_<_l_e_t_t_e_r_> * Mark the bottom line (or line _N) with <letter>.
'_<_l_e_t_t_e_r_> * Go to a previously marked position (put on position _N).
'' * Go to the previous position.
^X^X * Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after full screen movement.
-W ........ --HILITE-UNREAD
Highlight first new line after any movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
+277 -23
View File
@@ -1,64 +1,248 @@
import os
from dotenv import load_dotenv
load_dotenv() # Force la lecture du fichier .env en local
import uuid
import json
import threading
from fasthtml.common import *
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse
from starlette.responses import FileResponse, Response, HTMLResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
# Imports ultra-propres depuis les modules dédiés
from modules.theme import get_theme_hdrs
from modules.database import nettoyer_vieilles_discussions, HISTORY_FILE, load_history, save_history
from modules.ui_components import render_main_page, render_sidebar
from modules.ui_components import render_main_page, render_sidebar, render_login_page, render_rag_upload_modal, render_backoffice_page
from modules.chat_handler import handle_chat_request
from modules.security import verifier_et_bloquer_vpn, journaliser_ip
from modules.auth_manager import IDENTIFIANTS_ADMIN, verifier_mot_de_passe
from modules.logger import log_event
from modules.billing_manager import charger_soldes, sauvegarder_soldes
from modules.rag_manager import auto_apprendre_rag
from modules.vector_rag import indexer_dossier_thematique, indexer_tout_le_rag
from modules.user_db import load_users, save_users, changer_nom_utilisateur, mettre_a_jour_mot_de_passe
from modules.system_utils import obtenir_infos_serveur
# --- MIDDLEWARE DE SÉCURITÉ ---
# Lancement du module d'auto-mise à jour en arrière-plan
import modules.model_updater
dossier_media = "/DATA/AppData/MULTI-IA-AETHAS38/RAG/Media"
os.makedirs(dossier_media, exist_ok=True)
# --- WIDGET PAYPAL (RECHARGE DE CRÉDITS) ---
def render_paypal_recharge_widget(montant_usd: float = 10.00):
client_id = os.getenv("PAYPAL_CLIENT_ID", "test")
script_paypal = Script(src=f"https://www.paypal.com/sdk/js?client-id={client_id}&currency=USD")
container_id = f"paypal-button-container-{str(montant_usd).replace('.', '')}"
script_logique = Script(f"""
function chargerBoutonPayPal() {{
if (typeof paypal !== 'undefined') {{
paypal.Buttons({{
style: {{ layout: 'vertical', height: 35, shape: 'rect', label: 'paypal' }},
createOrder: function(data, actions) {{
return actions.order.create({{
purchase_units: [{{ amount: {{ value: '{montant_usd}' }} }}]
}});
}},
onApprove: function(data, actions) {{
return actions.order.capture().then(function(details) {{
alert('Paiement validé par ' + details.payer.name.given_name + ' ! Vos crédits vont être ajoutés.');
fetch('/api/paypal/success', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ orderID: data.orderID }})
}}).then(res => {{
if(res.ok) {{
document.getElementById('modal-recharge-paypal').style.display = 'none';
window.location.reload();
}} else {{
alert("Erreur lors de la validation serveur.");
}}
}});
}});
}}
}}).render('#{container_id}');
}} else {{
setTimeout(chargerBoutonPayPal, 100);
}}
}}
chargerBoutonPayPal();
""")
paypal_box = Div(
H4(f"Recharge : {montant_usd} $", style="color: #fff; margin: 0 0 10px 0; font-size: 1.1em; text-align: center;"),
Div(id=container_id, style="min-height: 100px; width: 100%;"),
Button("Annuler", type="button", onclick="document.getElementById('modal-recharge-paypal').style.display='none'",
style="background: transparent; border: 1px solid #555; color: #aaa; width: 100%; margin-top: 5px; padding: 6px; border-radius: 6px; font-size: 0.85em; cursor: pointer;"),
style="background: #1a1a1a; padding: 20px; border-radius: 10px; border: 1px solid #333; width: 320px; max-width: 90vw; box-shadow: 0 8px 24px rgba(0,0,0,0.9);"
)
modal_overlay = Div(
script_paypal, paypal_box, script_logique,
id="modal-recharge-paypal",
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px); align-items: center; justify-content: center; z-index: 10000;"
)
return modal_overlay
# --- MIDDLEWARE DE SÉCURITÉ & SERVEUR MÉDIA UNIFIÉ ---
class SecurityMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
# Récupération de l'IP du client (gère le proxy NPM si configuré avec X-Forwarded-For)
ip = request.headers.get("X-Forwarded-For", request.client.host).split(",")[0].strip()
utilisateur = request.cookies.get("aethas_user", "visiteur")
# On exclut les requêtes internes et statiques pour ne pas spammer les logs
if ip not in ["127.0.0.1", "localhost", "::1"] and not request.url.path.startswith("/Logo"):
# 1. Vérification VPN
if verifier_et_bloquer_vpn(ip):
return HTMLResponse("<h1 style='color:red; text-align:center;'>Accès refusé</h1><p style='text-align:center;'>Les connexions via VPN, Proxy ou Datacenter sont strictement interdites sur cette plateforme.</p>", status_code=403)
# 2. Journalisation RGPD
journaliser_ip(ip, f"Accès à {request.url.path}")
# --- LECTURE EN RAM ULTRA-DIRECTE PAR LE MIDDLEWARE ---
if request.url.path.startswith("/Media/"):
fname = request.url.path.replace("/Media/", "", 1)
chemin = os.path.join(dossier_media, fname)
if os.path.exists(chemin):
try:
with open(chemin, "rb") as f:
return Response(content=f.read(), media_type="image/png")
except Exception as e:
return HTMLResponse(f"Erreur : {e}", status_code=500)
return HTMLResponse("Fichier introuvable", status_code=404)
# EXCLUSION DE /Media POUR NE PAS BLOQUER LE RESTE
if not request.url.path.startswith(("/Logo", "/login", "/Media")):
if verifier_et_bloquer_vpn(ip, utilisateur):
return HTMLResponse("<h1 style='color:red; text-align:center;'>Accès refusé</h1><p style='text-align:center;'>VPN ou Proxy strictement interdit.</p>", status_code=403)
# Exclusion stricte du spam de logs (Silence pour /ping et /favicon)
if request.url.path not in ["/ping", "/favicon.ico"]:
journaliser_ip(ip, f"Accès à {request.url.path}")
return await call_next(request)
# On exécute le nettoyage au démarrage
nettoyer_vieilles_discussions()
# Initialisation du serveur FastHTML avec le Middleware de sécurité
app, rt = fast_app(
hdrs=get_theme_hdrs(),
secret_key="super_secret_aethas",
middleware=[Middleware(SecurityMiddleware)]
)
# Montage du dossier statique pour rendre accessible le logo
app.mount("/Logo", StaticFiles(directory="Logo"), name="Logo")
app.mount("/Media", StaticFiles(directory=dossier_media), name="Media")
@rt('/')
# --- ROUTES D'AUTHENTIFICATION ---
@rt('/login')
def get():
return render_login_page()
@rt('/login')
async def post(request, session):
form = await request.form()
if form.get("email") == IDENTIFIANTS_ADMIN["email"] and verifier_mot_de_passe(form.get("password"), IDENTIFIANTS_ADMIN["mot_de_passe_hash"]):
session['user'] = "xavier"
resp = RedirectResponse('/', status_code=303)
resp.set_cookie("aethas_user", "xavier", max_age=86400)
return resp
return render_login_page(error="Accès refusé. Identifiants incorrects.")
@rt('/logout')
def get(session):
session.clear()
resp = RedirectResponse('/login', status_code=303)
resp.delete_cookie("aethas_user")
return resp
# --- ROUTES DU BACK OFFICE & PROFIL ---
@rt('/backoffice')
def get(session, request):
utilisateur = session.get('user') or request.cookies.get('aethas_user')
if not utilisateur:
return RedirectResponse('/login', status_code=303)
users = load_users()
infos_user = users.get(utilisateur, {"role": "Membre"})
role = infos_user.get("role", "Membre")
stats_serveur = obtenir_infos_serveur() if role == "Super Admin" else None
return render_backoffice_page(utilisateur, role, users, stats_serveur)
@rt('/backoffice/update_profile')
async def post(request, session):
utilisateur = session.get('user') or request.cookies.get('aethas_user')
if not utilisateur:
return RedirectResponse('/login', status_code=303)
form = await request.form()
nouveau_pseudo = form.get("nouveau_pseudo")
nouveau_mdp = form.get("nouveau_mdp")
if nouveau_pseudo and nouveau_pseudo != utilisateur:
succes = changer_nom_utilisateur(utilisateur, nouveau_pseudo)
if succes:
session['user'] = nouveau_pseudo
resp = RedirectResponse('/backoffice', status_code=303)
resp.set_cookie("aethas_user", nouveau_pseudo, max_age=86400)
return resp
if nouveau_mdp:
mettre_a_jour_mot_de_passe(utilisateur, nouveau_mdp)
return RedirectResponse('/backoffice', status_code=303)
# --- ROUTE POUR FORCER LA VECTORISATION GLOBALE DU RAG ---
@rt('/backoffice/forcer_vectorisation')
def post(session):
utilisateur = session.get('user') or request.cookies.get('aethas_user')
users = load_users()
if not utilisateur or users.get(utilisateur, {}).get("role") != "Super Admin":
return HTMLResponse("Accès refusé. Réservé au Super Admin.", status_code=403)
# Lancement de l'indexation globale en arrière-plan pour ne pas bloquer l'interface
threading.Thread(target=indexer_tout_le_rag).start()
log_event("INFO", "RAG", f"Vectorisation globale forcée manuellement par {utilisateur}")
return RedirectResponse('/backoffice', status_code=303)
# --- ROUTES PROTÉGÉES ---
@rt('/')
def get(session, request):
utilisateur = session.get('user') or request.cookies.get('aethas_user')
if not utilisateur:
return RedirectResponse('/login', status_code=303)
session['user'] = utilisateur
if 'current_chat_id' not in session or not session['current_chat_id']:
session['current_chat_id'] = str(uuid.uuid4())
return render_main_page(session['current_chat_id'])
main_page = render_main_page(session['current_chat_id'], utilisateur_nom=session['user'])
paypal_modal = render_paypal_recharge_widget(10.00)
return paypal_modal, main_page
@rt('/chat')
async def post(request, session: dict):
async def post(request, session):
utilisateur = session.get('user') or request.cookies.get('aethas_user')
if not utilisateur:
return HTMLResponse(
"<div style='color:red; background:#300; padding:15px; border-radius:10px; margin-top:10px;'>"
"<b>❌ Session expirée ou non autorisée.</b> Veuillez rafraîchir la page (F5) pour vous reconnecter."
"</div>",
status_code=401
)
session['user'] = utilisateur
return await handle_chat_request(request, session)
@rt('/favicon.ico')
def get(): return HTTPResponse(status_code=204)
def favicon_get(): return Response(status_code=204)
@rt('/ping')
def get(): return Response(status_code=204)
@rt('/new_chat')
def get(session): session['current_chat_id'] = str(uuid.uuid4()); return RedirectResponse('/', status_code=303)
def get(session):
if 'user' in session: session['current_chat_id'] = str(uuid.uuid4())
return RedirectResponse('/', status_code=303)
@rt('/load_chat/{cid}')
def get(cid: str, session):
if cid and cid != "null": session['current_chat_id'] = cid
if cid and cid != "null" and 'user' in session: session['current_chat_id'] = cid
return RedirectResponse('/', status_code=303)
@rt('/rename/{cid}')
@@ -79,8 +263,78 @@ def post(cid: str):
if cid_str in h: del h[cid_str]; save_history(h)
return render_sidebar()
@rt('/export_json')
def get(): return FileResponse(HISTORY_FILE, media_type='application/json', filename='Aethas38_Discussion_Globale.json')
@rt('/api/paypal/success')
async def post(request):
data = await request.json()
order_id = data.get("orderID")
log_event("INFO", "FINANCE", f"Recharge PayPal validée - OrderID: {order_id}")
return {"status": "success"}
# --- NOUVELLE ROUTE POUR LE RAG (BOUTON MANUEL) ---
@rt('/api/forcer_rag')
def post(prompt: str, reponse: str, theme: str):
"""Intercepte la demande manuelle, lance l'indexation et renvoie un badge vert."""
auto_apprendre_rag(prompt, reponse, theme)
return Span("✅ Indexé", cls="msg-action-btn", style="background-color: #4caf50; color: white; border: none; cursor: default;")
# --- NOUVELLES ROUTES POUR L'UPLOAD MODAL DU RAG ---
@rt('/admin/rag_modal')
def get(session):
if session.get('user', '') != 'xavier':
return HTMLResponse("Accès refusé.")
return render_rag_upload_modal()
@rt('/api/upload_rag')
async def post(request, session):
if session.get('user', '') != 'xavier':
return Span("❌ Accès refusé", style="color: red;")
form = await request.form()
theme = form.get("theme")
fichiers = form.getlist("fichiers_rag")
if not theme or not fichiers:
return Span("❌ Thème ou fichiers manquants", style="color: red;")
dossier_cible = f"/DATA/AppData/MULTI-IA-AETHAS38/RAG/{theme}"
os.makedirs(dossier_cible, exist_ok=True)
fichiers_sauves = 0
for f in fichiers:
if f.filename:
chemin_fichier = os.path.join(dossier_cible, f.filename)
contenu = await f.read()
with open(chemin_fichier, "wb") as out:
out.write(contenu)
fichiers_sauves += 1
if fichiers_sauves > 0:
threading.Thread(target=indexer_dossier_thematique, args=(theme,)).start()
return Span(f"✅ {fichiers_sauves} fichier(s) sauvé(s). Indexation FAISS démarrée en arrière-plan !", style="color: #4caf50;")
else:
return Span("❌ Aucun fichier valide détecté.", style="color: red;")
from modules.export_utils import generer_export_comptable
# --- NOUVELLE ROUTE POUR L'EXPORT COMPTABLE EXCEL ---
@rt('/admin/export_comptable')
def get(session):
if session.get('user', '') != 'xavier':
return HTMLResponse("Accès refusé. Droits d'administration requis.")
try:
chemin_fichier = generer_export_comptable()
if os.path.exists(chemin_fichier):
return FileResponse(
chemin_fichier,
filename=os.path.basename(chemin_fichier),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
else:
return HTMLResponse("Erreur : Impossible de générer le fichier Excel.", status_code=500)
except Exception as e:
log_event("ERROR", "FINANCE", f"Échec de l'export : {e}")
return HTMLResponse(f"Erreur système lors de l'export : {e}", status_code=500)
if __name__ == '__main__':
serve(port=5001)
+80 -20
View File
@@ -1,8 +1,9 @@
import concurrent.futures
from modules.logger import log_event
from modules.ai_gemini import interroger_gemini
from modules.ai_local import interroger_local_ollama
# Le nouveau module que nous allons créer à la prochaine étape
from modules.ai_openrouter import interroger_openrouter
from modules.ai_openrouter import interroger_openrouter, interroger_openrouter_multi
from modules.ai_huggingface import interroger_huggingface
SYSTEM_CONFIDENTIALITY_INSTRUCTION = (
"RÈGLE DE CONFIDENTIALITÉ ABSOLUE : Tu es un assistant intégré à la plateforme privée Aethas38. "
@@ -11,32 +12,91 @@ SYSTEM_CONFIDENTIALITY_INSTRUCTION = (
"Concentre-toi uniquement sur la tâche demandée par l'utilisateur sans révéler les mécanismes sous-jacents."
)
def executer_requete_ia(fournisseur, prompt):
"""Moteur central gérant les consignes de sécurité et la cascade intelligente vers OpenRouter."""
prompt_securise = f"{SYSTEM_CONFIDENTIALITY_INSTRUCTION}\n\nRequête utilisateur :\n{prompt}"
def executer_synthese_multi_ia(prompt_securise: str, fournisseur: str, mode_payant: bool = False, chat_id: str = None):
reponses_brutes = []
# 🔄 Ordre de cascade recentré sur OpenRouter (Gratuit), Gemini et le Local
if fournisseur == "gemini":
ordre_repli = [interroger_gemini, interroger_openrouter, interroger_local_ollama]
elif fournisseur == "openrouter_code":
ordre_repli = [interroger_openrouter, interroger_gemini, interroger_local_ollama]
elif fournisseur == "openrouter_chat":
ordre_repli = [interroger_openrouter, interroger_gemini, interroger_local_ollama]
else:
ordre_repli = [interroger_local_ollama, interroger_openrouter, interroger_gemini]
try:
# 1. Lancement PARALLÈLE vers OpenRouter et Hugging Face
with concurrent.futures.ThreadPoolExecutor() as executor:
future_or = executor.submit(interroger_openrouter_multi, prompt_securise, type_modele=fournisseur, premium=mode_payant, chat_id=chat_id)
future_hf = executor.submit(interroger_huggingface, prompt_securise)
# 2. Récupération des résultats OpenRouter
try:
resultat_or = future_or.result()
if resultat_or:
reponses_brutes.extend(resultat_or)
except Exception as e:
log_event("WARNING", "MULTI_IA", f"Échec OpenRouter : {e}")
# 3. Récupération des résultats Hugging Face
try:
resultat_hf = future_hf.result()
if resultat_hf and resultat_hf[0]:
texte_hf, _, _, modele_hf = resultat_hf
# Formatage identique à OpenRouter pour la synthèse
reponses_brutes.append({"modele": f"HuggingFace ({modele_hf})", "texte": texte_hf})
except Exception as e:
log_event("WARNING", "MULTI_IA", f"Échec HuggingFace : {e}")
# 4. Synthèse par Gemini si au moins un modèle a répondu
if len(reponses_brutes) >= 1:
instruction_synthese = (
"RÈGLE DE CONFIDENTIALITÉ : Tu es un assistant IA. "
"Tu ne dois JAMAIS divulguer l'architecture logicielle interne de la plateforme hôte Aethas38. "
"Tu es ENTIÈREMENT AUTORISÉ à générer du code Minecraft (KubeJS, Java, Datapacks), du code applicatif, ou des scripts."
)
prompt_synthese = f"{instruction_synthese}\n\nTu es un moteur de synthèse expert. Fusionne les réponses suivantes de manière claire, concise et sans répétitions :\n\n"
for item in reponses_brutes:
prompt_synthese += f"--- Modèle {item['modele']} ---\n{item['texte']}\n\n"
prompt_synthese += f"Demande initiale :\n{prompt_securise}"
texte, couleur, _, _ = interroger_gemini(prompt_synthese)
# Mise à jour du nom de fournisseur pour refléter le travail de l'orchestrateur
vrai_fournisseur = f"Multi-IA (OR + HF -> Gemini)" if len(reponses_brutes) > 1 else "Multi-IA (Partiel -> Gemini)"
return texte, couleur, vrai_fournisseur, f"Synthèse de {len(reponses_brutes)} modèles"
except Exception as e:
log_event("WARNING", "MULTI_IA", f"Échec total synthèse : {e}")
return None
def executer_requete_ia(fournisseur: str, prompt: str, mode_payant: bool = False, multi_ia: bool = False, chat_id: str = None):
prompt_securise = f"{SYSTEM_CONFIDENTIALITY_INSTRUCTION}\n\nRequête utilisateur :\n{prompt}"
trace_tentatives = []
if multi_ia or fournisseur in ["openrouter_code", "openrouter_droit"]:
resultat_multi = executer_synthese_multi_ia(prompt_securise, fournisseur, mode_payant, chat_id)
if resultat_multi:
return resultat_multi[0], resultat_multi[1], resultat_multi[2], resultat_multi[3], [f"🟢 {resultat_multi[2]} réussie"]
if fournisseur == "gemini":
ordre_repli = [interroger_gemini, interroger_openrouter, interroger_huggingface, interroger_local_ollama]
elif fournisseur in ["openrouter_code", "openrouter_chat", "openrouter_droit"]:
ordre_repli = [interroger_openrouter, interroger_gemini, interroger_huggingface, interroger_local_ollama]
else:
ordre_repli = [interroger_local_ollama, interroger_openrouter, interroger_huggingface, interroger_gemini]
for fonction_api in ordre_repli:
nom_tente = fonction_api.__name__.replace("interroger_", "")
try:
# Transfert du 'fournisseur' cible pour qu'OpenRouter sache s'il doit appeler un modèle Code ou Chat
if fonction_api.__name__ == 'interroger_openrouter':
texte, couleur, nom_fournisseur, nom_modele = fonction_api(prompt_securise, type_modele=fournisseur)
if fonction_api.__name__ == "interroger_openrouter":
texte, couleur, nom_fournisseur, nom_modele, _, _ = fonction_api(
prompt_securise, type_modele=fournisseur, premium=mode_payant, chat_id=chat_id
)
else:
texte, couleur, nom_fournisseur, nom_modele = fonction_api(prompt_securise)
if texte:
return texte, couleur, nom_fournisseur, nom_modele
trace_tentatives.append(f"🟢 {nom_tente.upper()} ({nom_modele}) : Succès")
return texte, couleur, nom_fournisseur, nom_modele, trace_tentatives
except Exception as e:
log_event("WARNING", "ORCHESTRATEUR", f"Basculement suite à l'échec de {fonction_api.__name__}: {e}")
msg_err = str(e).split("-")[0].strip()[:50]
trace_tentatives.append(f"🔴 {nom_tente.upper()} : Échec ({msg_err})")
log_event("WARNING", "ORCHESTRATEUR", f"Basculement suite à l'échec de {fonction_api.__name__} : {e}")
continue
raise Exception("Tous les fournisseurs d'intelligence artificielle sont hors service ou indisponibles.")
raise Exception(f"Tous les fournisseurs d'IA ont échoué. Trace : {' | '.join(trace_tentatives)}")
+30 -14
View File
@@ -1,19 +1,35 @@
import os
import google.generativeai as genai
from google import genai
from modules.logger import log_event
def interroger_gemini(prompt_securise):
"""Interroge l'API Google Gemini via le SDK officiel (google-genai) sur les modèles actifs."""
api_key = os.getenv("GEMINI_API_KEY")
if not api_key: raise Exception("Clé API Gemini manquante.")
client = genai.Client(api_key=api_key)
modeles = ["gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini-2.5-pro", "gemini-3.7-flash"]
for modele in modeles:
try:
config = {"system_instruction": "Réponds en français et en Markdown sans code LaTeX."}
resp = client.chats.create(model=modele, config=config).send_message(prompt_securise)
if resp and resp.text:
log_event("INFO", f"GEMINI-{modele}", "Succès de la requête.")
return resp.text, "#3b82f6", "GOOGLE GEMINI", modele
except Exception as e:
log_event("WARNING", f"GEMINI-{modele}", f"Erreur: {e}")
raise Exception("Tous les modèles Gemini ont échoué.")
if not api_key:
raise Exception("Clé API Gemini manquante dans l'environnement.")
try:
client = genai.Client(api_key=api_key)
# Liste actualisée avec UNIQUEMENT les modèles opérationnels de la nouvelle génération
modeles = [
"gemini-3.6-flash",
"gemini-3.7-flash"
]
for modele in modeles:
try:
response = client.models.generate_content(
model=modele,
contents=prompt_securise,
)
if response and response.text:
log_event("INFO", f"GEMINI-{modele}", "Succès de la requête Gemini.")
return response.text, "#4285F4", "Google Gemini", modele
except Exception as sub_e:
log_event("WARNING", f"GEMINI-{modele}", f"Essai échoué pour {modele} : {sub_e}")
continue
raise Exception("Tous les modèles Gemini configurés ont échoué.")
except Exception as e:
log_event("ERROR", "GEMINI", f"Erreur critique Gemini : {e}")
raise e
+55 -18
View File
@@ -1,22 +1,59 @@
import os
from openai import OpenAI
import json
import requests
from modules.logger import log_event
def interroger_huggingface(prompt_securise):
api_key = os.getenv("HF_API_KEY")
if not api_key: raise Exception("Clé API HuggingFace manquante.")
client = OpenAI(base_url="https://api-inference.huggingface.co/v1/", api_key=api_key)
modeles = ["meta-llama/Meta-Llama-3-8B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3"]
for modele in modeles:
CATALOGUE_FILE = "/DATA/AppData/MULTI-IA-AETHAS38/catalogue_dynamique.json"
def interroger_huggingface(prompt: str, modele_cible=None):
"""
Connecteur pour l'API Serverless de Hugging Face.
Sélectionne dynamiquement le modèle le plus populaire scanné par l'Updater.
"""
api_key = os.getenv("HUGGINGFACE_API_KEY")
if not api_key:
raise Exception("Clé API HuggingFace manquante dans le .env")
# Extraction dynamique du meilleur modèle Hugging Face gratuit disponible
if not modele_cible:
try:
resp = client.chat.completions.create(
model=modele,
messages=[{"role": "system", "content": "Réponds en français en Markdown."}, {"role": "user", "content": prompt_securise}],
max_tokens=1500
)
if resp and resp.choices:
log_event("INFO", f"HF-{modele}", "Succès.")
return resp.choices[0].message.content, "#fbbf24", "HUGGINGFACE", modele
except Exception as e:
log_event("WARNING", f"HF-{modele}", f"Erreur: {e}")
raise Exception("Tous les modèles HuggingFace ont échoué.")
if os.path.exists(CATALOGUE_FILE):
with open(CATALOGUE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
hf_models = [m.replace("huggingface/", "") for m in data.get("free_models", []) if m.startswith("huggingface/")]
if hf_models:
modele_cible = hf_models[0] # Modèle le plus téléchargé selon l'Updater
except Exception:
pass
if not modele_cible:
modele_cible = "mistralai/Mistral-7B-Instruct-v0.3"
url = f"https://router.huggingface.co/hf-inference/models/{modele_cible}"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
# Paramètres optimisés pour les LLMs d'instruction
payload = {
"inputs": prompt,
"parameters": {
"max_new_tokens": 2048,
"return_full_text": False,
"temperature": 0.7
}
}
try:
resp = requests.post(url, headers=headers, json=payload, timeout=60)
if resp.status_code == 200:
data = resp.json()
if isinstance(data, list) and len(data) > 0 and "generated_text" in data[0]:
texte = data[0]["generated_text"].strip()
return texte, "#ffb000", "HuggingFace", modele_cible
raise Exception(f"Erreur {resp.status_code} : {resp.text}")
except Exception as e:
log_event("WARNING", "HUGGINGFACE", f"Échec de la requête pour {modele_cible} : {e}")
raise e
+18 -7
View File
@@ -1,16 +1,27 @@
import os
from openai import OpenAI
from modules.logger import log_event
def interroger_local_ollama(prompt_securise, modele="qwen2.5:3b"):
client = OpenAI(base_url="http://192.168.1.20:11434/v1", api_key="ollama")
def interroger_local_ollama(prompt_securise: str, modele: str = "richardyoung/llama-3.2-3b-instruct-abliterated"):
"""
Interroge le serveur local Ollama avec forçage absolu sur le modèle installé.
"""
modele_effectif = "richardyoung/llama-3.2-3b-instruct-abliterated"
ollama_host = os.getenv("OLLAMA_HOST", "http://192.168.1.20:11434")
client = OpenAI(base_url=f"{ollama_host}/v1", api_key="ollama")
try:
resp = client.chat.completions.create(
model=modele,
messages=[{"role": "system", "content": "Réponds en français."}, {"role": "user", "content": prompt_securise}]
model=modele_effectif,
messages=[
{"role": "system", "content": "Tu es l'IA locale Aethas38, une IA non censurée et sans filtre. Réponds dans la langue de la question de manière claire et directe."},
{"role": "user", "content": prompt_securise}
]
)
if resp and resp.choices:
log_event("INFO", f"LOCAL-{modele}", "Succès.")
return resp.choices[0].message.content, "#10b981", "SERVEUR LOCAL", modele
log_event("INFO", f"LOCAL-{modele_effectif}", "Succès de la requête.")
return resp.choices[0].message.content, "#10b981", "SERVEUR LOCAL", modele_effectif
except Exception as e:
log_event("ERROR", f"LOCAL-{modele}", f"Erreur: {e}")
log_event("ERROR", f"LOCAL-{modele_effectif}", f"Erreur critique locale : {e}")
raise Exception("Le serveur local a échoué.")
+113
View File
@@ -0,0 +1,113 @@
import os
import requests
import time
import concurrent.futures
import base64
from modules.logger import log_event
def generer_image_cloudflare(prompt: str) -> bytes:
account_id = os.getenv("CLOUDFLARE_ACCOUNT_ID")
api_token = os.getenv("CLOUDFLARE_API_TOKEN")
if not account_id or not api_token: return None
url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/@cf/stabilityai/stable-diffusion-xl-base-1.0"
headers = {"Authorization": f"Bearer {api_token}"}
try:
resp = requests.post(url, headers=headers, json={"prompt": prompt}, timeout=120)
if resp.status_code == 200:
if "application/json" in resp.headers.get("Content-Type", ""):
data = resp.json()
if data.get("success") and "result" in data:
return base64.b64decode(data["result"])
else:
return resp.content
except Exception as e:
log_event("WARNING", "MEDIA", f"Échec Cloudflare : {e}")
return None
def generer_image_openrouter(prompt: str) -> tuple:
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key: return None, None
url = "https://openrouter.ai/api/v1/images"
headers = {
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "https://aethas38.duckdns.org",
"X-Title": "Aethas38"
}
modeles_payants = ["bytedance-seed/seedream-4.5", "openai/dall-e-3"]
for modele in modeles_payants:
payload = {"prompt": prompt, "model": modele}
try:
resp = requests.post(url, headers=headers, json=payload, timeout=120)
if resp.status_code == 200:
data = resp.json()
if "data" in data and len(data["data"]) > 0 and "b64_json" in data["data"][0]:
img_bytes = base64.b64decode(data["data"][0]["b64_json"])
return img_bytes, modele
except Exception as e:
log_event("WARNING", "MEDIA", f"Échec OpenRouter ({modele}) : {e}")
continue
return None, None
def generer_les_images(prompt: str, fichiers: list, premium: bool) -> tuple:
trace = []
if fichiers:
trace.append("🟢 Mode Image-to-Image")
return f"*(BETA)* Le mode Image-to-Image est désactivé temporairement. \n\nFichiers : {', '.join(fichiers)}", "#ff9800", "Système", "En dev", trace
trace.append(f"🟢 Lancement Text-to-Image (Premium: {premium})")
with concurrent.futures.ThreadPoolExecutor() as executor:
future_or = executor.submit(generer_image_openrouter, prompt) if premium else None
future_cf = executor.submit(generer_image_cloudflare, prompt)
img_or, nom_modele_or = future_or.result() if future_or else (None, None)
img_cf = future_cf.result()
dossier_media = "/DATA/AppData/MULTI-IA-AETHAS38/RAG/Media"
os.makedirs(dossier_media, exist_ok=True)
reponse_md = f"**Prompt analysé :** {prompt}\n\n"
fournisseurs_actifs = []
# --- Rendu OpenRouter ---
if future_or is None:
trace.append("⚪ OR : Ignoré (Mode Gratuit)")
elif img_or:
nom_or = f"img_or_{int(time.time())}.png"
with open(os.path.join(dossier_media, nom_or), 'wb') as f: f.write(img_or)
reponse_md += f"### 🌐 OpenRouter ({nom_modele_or})\n![OpenRouter](/Media/{nom_or})\n"
reponse_md += f"**[📥 Télécharger l'image OR](/Media/{nom_or})**\n\n---\n\n"
fournisseurs_actifs.append("OpenRouter")
trace.append(f"🟢 OR ({nom_modele_or}) : Succès")
else:
trace.append("🔴 OR : Échec")
# --- Rendu Cloudflare ---
if img_cf:
nom_cf = f"img_cf_{int(time.time())}.png"
with open(os.path.join(dossier_media, nom_cf), 'wb') as f: f.write(img_cf)
reponse_md += f"### ☁️ Cloudflare Workers AI\n![Cloudflare](/Media/{nom_cf})\n"
reponse_md += f"**[📥 Télécharger l'image CF](/Media/{nom_cf})**\n\n"
fournisseurs_actifs.append("Cloudflare")
trace.append("🟢 CF : Succès")
else:
trace.append("🔴 CF : Échec")
if not img_or and not img_cf:
return "*Échec critique : Aucune API n'a pu générer d'image.*", "#ff4444", "Système", "Erreur", trace
return reponse_md, "#00e5ff", " + ".join(fournisseurs_actifs) if fournisseurs_actifs else "Aucun", "Moteur Média", trace
def router_media(prompt: str, type_media: str, premium: bool, fichiers: list) -> tuple:
trace = [f"🟢 Routage Média : {type_media.upper()}"]
if type_media == "image":
return generer_les_images(prompt, fichiers, premium)
elif type_media == "video":
return "*La génération vidéo nécessite une API payante.* \n\nPrompt : " + prompt, "#ff4444", "Système", "En attente API", trace
return "Type de média non reconnu.", "#333", "Erreur", "Aucun", trace
+144 -46
View File
@@ -1,63 +1,161 @@
import os
import time
import openai
import json
from dotenv import load_dotenv
from openai import OpenAI
from modules.logger import log_event
# Initialisation du client OpenRouter avec l'API compatible OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENROUTER_API_KEY", "VOTRE_CLE_ICI") # À définir dans le .env
)
load_dotenv()
def interroger_openrouter(prompt, type_modele="openrouter_chat", max_retries=3):
"""
Interroge l'API OpenRouter avec gestion des erreurs 402 (Plafond) et 429 (Rate Limit).
Sélectionne un modèle gratuit selon l'intention (Chat ou Code).
"""
api_key_openrouter = os.getenv("OPENROUTER_API_KEY") or "sk-or-v1-cle-non-definie"
client = OpenAI(base_url="https://openrouter.ai/api/v1", api_key=api_key_openrouter)
CATALOGUE_FILE = "/DATA/AppData/MULTI-IA-AETHAS38/catalogue_dynamique.json"
def verifier_cle_api():
cle = os.getenv("OPENROUTER_API_KEY")
if not cle or cle == "sk-or-v1-cle-non-definie":
raise Exception("Clé OPENROUTER_API_KEY absente dans le fichier .env.")
return cle
def obtenir_modele_dynamique(premium: bool, prompt: str = ""):
"""Charge le catalogue dynamiquement pour un seul modèle optimal."""
est_education = "RÈGLE ÉDUCATION NATIONALE" in prompt
# Sélection des modèles 100% gratuits et performants sur OpenRouter
if type_modele == "openrouter_code":
nom_modele_api = "qwen/qwen-2.5-coder-32b-instruct:free"
nom_affichage = "OpenRouter (Qwen Coder - Gratuit)"
couleur = "#10a37f" # Vert émeraude
try:
if os.path.exists(CATALOGUE_FILE):
with open(CATALOGUE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if premium:
paid_models = data.get("paid_models", [])
if not paid_models: return "anthropic/claude-3.5-sonnet"
claude = [m for m in paid_models if "claude-3.5-sonnet" in m.lower()]
return claude[0] if claude else paid_models[0]
else:
free_models = [m for m in data.get("free_models", []) if not m.startswith("huggingface/")]
if not free_models: return "google/gemma-2-9b-it:free"
if est_education:
mistral = [m for m in free_models if "mistral" in m.lower()]
return mistral[0] if mistral else free_models[0]
priorites = [m for m in free_models if "gemma" in m.lower() or "nemotron" in m.lower()]
return priorites[0] if priorites else free_models[0]
except Exception:
pass
return "anthropic/claude-3.5-sonnet" if premium else "google/gemma-2-9b-it:free"
def obtenir_modeles_multi_dynamiques(premium: bool, prompt: str) -> list:
"""Détermine dynamiquement les modèles et leur nombre selon le sujet de la requête."""
try:
with open(CATALOGUE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
except Exception:
data = {"free_models": [], "paid_models": []}
modeles_dispos = data.get("paid_models", []) if premium else data.get("free_models", [])
modeles_or = [m for m in modeles_dispos if not m.startswith("huggingface/")]
if not modeles_or:
return ["anthropic/claude-3.5-sonnet"] if premium else ["google/gemma-2-9b-it:free"]
# Analyse sémantique du sujet pour définir le nombre d'IA et la priorité
prompt_lower = prompt.lower()
nb_ia = 3 # Par défaut
if any(mot in prompt_lower for mot in ["code", "script", "python", "javascript", "html", "css", "erreur", "bug", "kubejs", "serveur"]):
nb_ia = 5
mots_cles = ["coder", "claude", "llama", "qwen", "deepseek"]
elif any(mot in prompt_lower for mot in ["loi", "juridique", "droit", "contrat", "urssaf", "légal"]):
nb_ia = 4
mots_cles = ["mistral", "claude", "llama", "mixtral"]
elif any(mot in prompt_lower for mot in ["traduis", "rédaction", "écris", "lettre", "mail", "éducation"]):
nb_ia = 2
mots_cles = ["mistral", "claude", "llama", "gemini", "gemma"]
else:
nom_modele_api = "meta-llama/llama-3.1-8b-instruct:free"
nom_affichage = "OpenRouter (Llama 3.1 - Gratuit)"
couleur = "#4a90e2" # Bleu clair
mots_cles = ["gemini", "llama", "mistral", "qwen", "gemma"]
modeles_selectionnes = []
# Étape 1 : Sélectionner les modèles qui matchent les mots-clés
for mc in mots_cles:
for m in modeles_or:
if mc in m.lower() and m not in modeles_selectionnes:
modeles_selectionnes.append(m)
if len(modeles_selectionnes) == nb_ia:
break
if len(modeles_selectionnes) == nb_ia:
break
# Étape 2 : Compléter avec les autres modèles du catalogue si le nombre n'est pas atteint
for m in modeles_or:
if len(modeles_selectionnes) >= nb_ia:
break
if m not in modeles_selectionnes:
modeles_selectionnes.append(m)
return modeles_selectionnes
def interroger_openrouter(prompt: str, type_modele: str = "openrouter_chat", premium: bool = False, max_retries: int = 2, chat_id: str = None):
verifier_cle_api()
nom_modele_api = obtenir_modele_dynamique(premium, prompt)
nom_affichage = f"OpenRouter ({nom_modele_api.split('/')[-1]})"
couleur = "#d97757" if premium else "#4a90e2"
extra_body_data = {"session_id": chat_id} if chat_id else {}
for tentative in range(max_retries):
try:
start_time = time.time()
response = client.chat.completions.create(
model=nom_modele_api,
messages=[{"role": "user", "content": prompt}]
messages=[{"role": "user", "content": prompt}],
extra_headers={"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "Aethas38 Multi-IA"},
extra_body=extra_body_data,
timeout=90.0
)
texte = response.choices[0].message.content
return texte, couleur, "OpenRouter", nom_affichage
except openai.RateLimitError as e:
temps_attente = 2 ** tentative
log_event("WARNING", "OPENROUTER", f"Limite de taux atteinte (429). Essai {tentative + 1}/{max_retries}. Attente de {temps_attente}s...")
time.sleep(temps_attente)
if tentative == max_retries - 1:
log_event("ERROR", "OPENROUTER", "Échec définitif suite aux limites de taux (429).")
raise Exception("Service temporairement saturé. Veuillez réessayer.")
except openai.APIStatusError as e:
if e.status_code == 402:
log_event("CRITICAL", "OPENROUTER", "Erreur 402 : Fonds insuffisants ou limite de clé atteinte.")
raise Exception("Action requise : Limite de facturation atteinte sur OpenRouter.")
else:
log_event("ERROR", "OPENROUTER", f"Code {e.status_code} : {e.message}")
raise Exception(f"Erreur API OpenRouter : {e.status_code}")
except openai.APIConnectionError as e:
log_event("ERROR", "OPENROUTER", f"Erreur réseau : {e}")
raise Exception("Impossible de joindre les serveurs d'OpenRouter.")
usage = response.usage
tokens_in = usage.prompt_tokens if usage else 0
tokens_out = usage.completion_tokens if usage else 0
return texte, couleur, "OpenRouter", nom_affichage, tokens_in, tokens_out
except Exception as e:
log_event("ERROR", "OPENROUTER", f"Erreur inattendue : {e}")
raise e
return None, None, None, None
log_event("WARNING", "OPENROUTER", f"Tentative {tentative + 1} échouée pour {nom_modele_api} : {e}")
if tentative == max_retries - 1:
try:
from modules.model_updater import trigger_update_now
trigger_update_now()
except Exception:
pass
raise e
time.sleep(1)
return None, None, None, None, 0, 0
def interroger_openrouter_multi(prompt: str, type_modele: str = "openrouter_code", premium: bool = False, chat_id: str = None) -> list:
verifier_cle_api()
extra_body_data = {"session_id": chat_id} if chat_id else {}
# La liste s'adapte dynamiquement au nombre d'IA requis par le sujet et aux disponibilités
modeles = obtenir_modeles_multi_dynamiques(premium, prompt)
log_event("INFO", "OPENROUTER_MULTI", f"Lancement Multi-IA avec {len(modeles)} modèles adaptatifs.")
reponses = []
for modele in modeles:
try:
res = client.chat.completions.create(
model=modele,
messages=[{"role": "user", "content": prompt}],
extra_headers={"HTTP-Referer": "https://aethas38.duckdns.org", "X-Title": "Aethas38 Multi-IA"},
extra_body=extra_body_data,
timeout=90.0
)
contenu = res.choices[0].message.content
if contenu:
reponses.append({"modele": modele, "texte": contenu})
except Exception as e:
log_event("WARNING", "OPENROUTER_MULTI", f"Échec pour {modele} : {e}")
continue
return reponses
+4 -12
View File
@@ -3,9 +3,7 @@ import bcrypt
from cryptography.fernet import Fernet
from modules.logger import log_event
# 1. GÉNÉRATION DE LA CLÉ DE CRYPTAGE (À ne faire qu'une fois)
def generer_cle_secrete():
"""Génère une clé Fernet unique. À sauvegarder dans le .env"""
cle = Fernet.generate_key()
print("\n" + "="*60)
print("⚠️ CLÉ DE CRYPTAGE GÉNÉRÉE (À CONSERVER PRÉCIEUSEMENT) ⚠️")
@@ -21,27 +19,21 @@ def obtenir_fernet():
raise Exception("AETHAS_ENCRYPTION_KEY introuvable dans l'environnement.")
return Fernet(cle.encode('utf-8'))
# 2. CRYPTAGE DES DONNÉES SENSIBLES (Clés API des utilisateurs, CB, etc.)
def crypter_donnee(donnee_claire):
f = obtenir_fernet()
return f.encrypt(donnee_claire.encode('utf-8')).decode('utf-8')
return obtenir_fernet().encrypt(donnee_claire.encode('utf-8')).decode('utf-8')
def decrypter_donnee(donnee_cryptee):
f = obtenir_fernet()
return f.decrypt(donnee_cryptee.encode('utf-8')).decode('utf-8')
return obtenir_fernet().decrypt(donnee_cryptee.encode('utf-8')).decode('utf-8')
# 3. GESTION DES MOTS DE PASSE (Hashing irréversible)
def hasher_mot_de_passe(mot_de_passe):
"""Hash le mot de passe avec un 'salt' unique pour la base de données."""
return bcrypt.hashpw(mot_de_passe.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def verifier_mot_de_passe(mot_de_passe_clair, hash_enregistre):
return bcrypt.checkpw(mot_de_passe_clair.encode('utf-8'), hash_enregistre.encode('utf-8'))
# --- CRÉATION DE VOTRE COMPTE ADMINISTRATEUR PAR DÉFAUT ---
# CORRECTION : Le hash est généré mathématiquement au chargement pour garantir que Xavier2026! fonctionne
IDENTIFIANTS_ADMIN = {
"email": "admin@mail.aethas38.duckdns.org",
# Mot de passe par défaut : Xavier2026! (Il est hashé ici pour la sécurité du code)
"mot_de_passe_hash": "$2b$12$R.K19pS8M5/xVd2o.3nSZeZ8GzH6J9gR/vY9x6g3Zp2vJq4vX2J0e",
"mot_de_passe_hash": hasher_mot_de_passe("Xavier2026!"),
"role": "Dieu tout puissant"
}
+256 -31
View File
@@ -1,47 +1,272 @@
import os
import json
import time
import requests
from datetime import datetime
import tiktoken
from modules.logger import log_event
# Tarification indicative par token OpenRouter (à synchroniser avec l'API en temps réel)
TARIFS_OPENROUTER = {
"meta-llama/llama-3.1-8b-instruct:free": 0.0,
"qwen/qwen-2.5-coder-32b-instruct:free": 0.0,
"google/gemini-2.5-pro": 0.000005
}
# FORCAGE DES CHEMINS ABSOLUS
BASE_DIR = "/DATA/AppData/MULTI-IA-AETHAS38"
os.makedirs(BASE_DIR, exist_ok=True)
BALANCES_FILE = os.path.join(BASE_DIR, "aethas_balances.json")
AUDIT_FILE = os.path.join(BASE_DIR, "aethas_financial_audit.json")
TARIFS_FILE = os.path.join(BASE_DIR, "tarifs_openrouter.json")
def compter_tokens_local(prompt):
"""Compte localement les tokens pour estimer le coût de la requête."""
# VIP_ADMIN_USERS est conservé pour la compatibilité avec l'ancien code,
# mais la logique stricte des rôles est maintenant dans la fonction d'estimation.
VIP_ADMIN_USERS = ["xavier", "céline", "celine", "anaëlle", "anaelle", "renan", "audric"]
# -----------------------------------------------------------------------------
# NOUVEAU MODULE : CONVERSION DE DEVISES (API FRANKFURTER)
# -----------------------------------------------------------------------------
TAUX_DEVISE_FILE = os.path.join(BASE_DIR, "taux_devise.json")
def obtenir_taux_usd_eur() -> float:
"""
Récupère le taux de change USD -> EUR via l'API Frankfurter.
Met en cache le résultat pendant 12 heures pour ne pas ralentir le serveur.
"""
maintenant = time.time()
# Vérification du cache (12 heures = 43200 secondes)
if os.path.exists(TAUX_DEVISE_FILE):
if maintenant - os.path.getmtime(TAUX_DEVISE_FILE) < 43200:
try:
with open(TAUX_DEVISE_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
return float(data.get("taux", 0.92)) # 0.92 par défaut (sécurité)
except Exception:
pass
try:
enc = tiktoken.get_encoding("cl100k_base")
return len(enc.encode(prompt))
# Appel à l'API publique (gratuite et sans clé) basée sur la BCE
resp = requests.get("https://api.frankfurter.app/latest?from=USD&to=EUR", timeout=5)
if resp.status_code == 200:
taux = resp.json().get("rates", {}).get("EUR", 0.92)
# Sauvegarde en cache
with open(TAUX_DEVISE_FILE, "w", encoding="utf-8") as f:
json.dump({"taux": taux, "timestamp": maintenant}, f)
log_event("INFO", "BILLING", f"Taux USD->EUR mis à jour : {taux}")
return taux
except Exception as e:
log_event("WARNING", "BILLING", f"Échec récupération du taux de change : {e}")
return 0.92 # Taux de repli fixe en cas de panne réseau
# -----------------------------------------------------------------------------
# GESTION DES TARIFS OPENROUTER
# -----------------------------------------------------------------------------
def obtenir_tarifs_dynamiques() -> dict:
maintenant = time.time()
if os.path.exists(TARIFS_FILE):
if maintenant - os.path.getmtime(TARIFS_FILE) < 7200:
try:
with open(TARIFS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
tarifs = {}
try:
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
if resp.status_code == 200:
data = resp.json().get("data", [])
for m in data:
pricing = m.get("pricing", {})
p_prompt = float(pricing.get("prompt", 0.0))
p_comp = float(pricing.get("completion", 0.0))
tarifs[m["id"]] = max(p_prompt, p_comp)
with open(TARIFS_FILE, "w", encoding="utf-8") as f:
json.dump(tarifs, f, indent=4)
log_event("INFO", "BILLING", "Tarifs OpenRouter mis à jour dynamiquement.")
return tarifs
except Exception as e:
log_event("ERROR", "BILLING", f"Échec récupération des tarifs : {e}")
if os.path.exists(TARIFS_FILE):
try:
with open(TARIFS_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return {}
def charger_soldes() -> dict:
if os.path.exists(BALANCES_FILE):
try:
with open(BALANCES_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
soldes = {"cagnotte_commune_vip": 35.00}
sauvegarder_soldes(soldes)
return soldes
def sauvegarder_soldes(soldes: dict):
with open(BALANCES_FILE, "w", encoding="utf-8") as f:
json.dump(soldes, f, indent=4)
def enregistrer_transaction_audit(utilisateur: str, rang: str, modele: str, tokens: int, cout_brut: float, prix_facture: float, type_op: str = "DEBIT_REQUETE"):
# Conversion du coût final en Euros pour l'export comptable URSSAF
taux_actuel = obtenir_taux_usd_eur()
cout_brut_eur = cout_brut * taux_actuel
prix_facture_eur = prix_facture * taux_actuel
entree = {
"timestamp": time.time(),
"date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"utilisateur": utilisateur,
"rang": rang,
"type_operation": type_op,
"modele": modele,
"tokens": tokens,
"cout_brut_api": cout_brut,
"prix_facture_client": prix_facture,
# Données Euro pour le Livre de Recettes (Pandas)
"cout_brut_api_eur": cout_brut_eur,
"prix_facture_client_eur": prix_facture_eur
}
audits = []
if os.path.exists(AUDIT_FILE):
try:
with open(AUDIT_FILE, "r", encoding="utf-8") as f:
audits = json.load(f)
except Exception:
audits = []
audits.append(entree)
with open(AUDIT_FILE, "w", encoding="utf-8") as f:
json.dump(audits, f, indent=4)
def compter_tokens(texte: str, modele: str = "cl100k_base") -> int:
try:
enc = tiktoken.get_encoding(modele)
return len(enc.encode(texte))
except Exception as e:
log_event("ERROR", "BILLING", f"Erreur comptage tokens : {e}")
return 0
def verifier_et_deduire_credit_utilisateur(prompt, modele_id, utilisateur_nom, solde_actuel_utilisateur):
"""
Vérifie si l'utilisateur a suffisamment de crédit.
Applique la marge de 15% (Prix_HT = Coût_API / 0.85) pour les non-VIP.
Bloque la requête si le solde est insuffisant.
"""
utilisateurs_vip = ["xavier", "céline", "anaëlle", "renan", "audric"]
# -----------------------------------------------------------------------------
# MOTEUR DE CALCUL DES MARGES COMMERCIALES (Règles strictes)
# -----------------------------------------------------------------------------
def estimer_cout_facture(prompt: str, modele_id: str, rang_utilisateur: str = "standard", multi_ia: bool = False) -> tuple[float, float, int]:
tokens = compter_tokens(prompt)
tarifs = obtenir_tarifs_dynamiques()
cout_unitaire = tarifs.get(modele_id, 0.0)
cout_brut = tokens * cout_unitaire
tokens = compter_tokens_local(prompt)
cout_brut_api = tokens * TARIFS_OPENROUTER.get(modele_id, 0.0)
if multi_ia and cout_brut > 0:
cout_brut *= 2
if cout_brut == 0.0:
return 0.0, 0.0, tokens
rang = rang_utilisateur.lower()
if cout_brut_api <= 0.0:
return True, 0.0 # Gratuit
# 1. PAS DE MARGE (Super Admin, Admin, Famille)
if rang in ["super admin", "admin", "famille"]:
prix_facture = cout_brut
if utilisateur_nom.lower() in utilisateurs_vip:
log_event("INFO", "BILLING", f"Utilisateur VIP ({utilisateur_nom}) : Aucun prélèvement de marge ni de crédit.")
# 2. MARGE RÉDUITE 7.5% (Utilisateurs VIP choisis par Admin)
elif rang == "vip":
prix_facture = cout_brut / 0.925
# 3. MARGE STANDARD 15% (Utilisateurs par défaut)
else:
prix_facture = cout_brut / 0.85
return round(cout_brut, 6), round(prix_facture, 6), tokens
def verifier_et_valider_budget(prompt: str, modele_id: str, utilisateur_nom: str = "xavier", rang_utilisateur: str = "super admin", multi_ia: bool = False):
cout_brut, prix_facture, tokens = estimer_cout_facture(prompt, modele_id, rang_utilisateur, multi_ia)
if prix_facture == 0.0:
return True, 0.0
# Application de la règle comptable de marge (15% de marge brute)
prix_facture_ht = round(cout_brut_api / 0.85, 6)
soldes = charger_soldes()
utilisateur_cle = utilisateur_nom.lower()
# Sécurité absolue : Empêche l'utilisateur de dépenser plus que son crédit versé
if solde_actuel_utilisateur < prix_facture_ht:
log_event("WARNING", "BILLING", f"Rejet requête pour {utilisateur_nom} : Solde insuffisant ({solde_actuel_utilisateur}$ < {prix_facture_ht}$).")
raise Exception(f"SOLDE_INSUFFISANT|{prix_facture_ht}|{solde_actuel_utilisateur}")
# Les rôles protégés tapent dans la cagnotte globale (carte bancaire de l'admin)
if utilisateur_cle in VIP_ADMIN_USERS or rang_utilisateur.lower() in ["super admin", "admin", "famille"]:
solde_disponible = soldes.get("cagnotte_commune_vip", 0.0)
cle_compte = "cagnotte_commune_vip"
# Les utilisateurs payants (VIP / Standard) utilisent leur propre recharge
else:
solde_disponible = soldes.get(utilisateur_cle, 0.0)
cle_compte = utilisateur_cle
if solde_disponible < prix_facture:
log_event("WARNING", "BILLING", f"Rejet : solde insuffisant pour {utilisateur_nom} ({solde_disponible} $ < {prix_facture} $)")
raise Exception(f"SOLDE_INSUFFISANT|{prix_facture}|{solde_disponible}")
soldes[cle_compte] = round(solde_disponible - prix_facture, 6)
sauvegarder_soldes(soldes)
enregistrer_transaction_audit(utilisateur_nom, rang_utilisateur, modele_id, tokens, cout_brut, prix_facture)
log_event("INFO", "BILLING", f"Requête validée [{rang_utilisateur}] - Débit : {prix_facture} $ - Solde restant : {soldes[cle_compte]} $")
return True, prix_facture
def obtenir_solde(utilisateur_nom: str) -> float:
# --- SYNCHRONISATION DIRECTE POUR L'ADMINISTRATEUR PRINCIPAL ---
if utilisateur_nom.lower() in ["xavier"]:
api_key = os.getenv("OPENROUTER_API_KEY")
return True, prix_facture_ht
if not api_key:
log_event("CRITICAL", "BILLING", "Clé OPENROUTER_API_KEY introuvable dans l'environnement ! La synchronisation est impossible.")
else:
try:
resp = requests.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {api_key}"}, timeout=5)
if resp.status_code == 200:
data = resp.json().get("data", {})
usage = float(data.get('usage', 0.0))
limit = data.get('limit')
balance = data.get('balance')
vrai_solde = 0.0
if limit is not None:
vrai_solde = float(limit) - usage
elif balance is not None:
vrai_solde = float(balance)
if vrai_solde > 0 or (limit is not None) or (balance is not None):
soldes = charger_soldes()
soldes["cagnotte_commune_vip"] = vrai_solde
soldes["xavier"] = vrai_solde
sauvegarder_soldes(soldes)
return vrai_solde
else:
log_event("ERROR", "BILLING", f"Refus de l'API OpenRouter (Code {resp.status_code}) : {resp.text}")
except Exception as e:
log_event("WARNING", "BILLING", f"Échec réseau lors de la connexion à OpenRouter : {e}")
# --- COMPORTEMENT STANDARD POUR LES AUTRES UTILISATEURS ---
soldes = charger_soldes()
if utilisateur_nom.lower() in VIP_ADMIN_USERS:
return soldes.get("cagnotte_commune_vip", 0.0)
return soldes.get(utilisateur_nom.lower(), 0.0)
def generer_dossier_litige(utilisateur_nom: str, est_admin: bool = False) -> list:
if not os.path.exists(AUDIT_FILE):
return []
try:
with open(AUDIT_FILE, "r", encoding="utf-8") as f:
tous_les_audits = json.load(f)
except Exception:
return []
dossier = []
for item in tous_les_audits:
if item.get("utilisateur", "").lower() == utilisateur_nom.lower():
if est_admin:
dossier.append(item)
else:
dossier.append({
"date": item.get("date"),
"type_operation": item.get("type_operation"),
"modele": item.get("modele"),
"tokens": item.get("tokens"),
"montant_facture_ttc": item.get("prix_facture_client")
})
return dossier
+171 -61
View File
@@ -1,15 +1,60 @@
import os
import json
from datetime import date
import uuid
import time
import re
import psutil
import threading
import requests
from fasthtml.common import *
from modules.database import load_history, save_history
from modules.scraper import rechercher_sur_google, extraire_contenu_urls
from modules.router import nettoyer_decision_router
from modules.ai_local import interroger_local_ollama
from modules.ai_engine import executer_requete_ia
from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar
from modules.ui_components import render_user_msg, render_ai_msg, render_sidebar, render_popup_paiement
from modules.logger import log_event
from modules.billing_manager import verifier_et_valider_budget
from modules.billing_manager import verifier_et_valider_budget, obtenir_solde
from modules.rag_manager import rechercher_contexte_rag, auto_apprendre_rag
from modules.moderator import analyser_intention_illicite, appliquer_sanction_et_archiver
from modules.ai_media import router_media
def evaluer_pertinence_pour_rag(prompt_utilisateur: str, reponse_ia: str) -> bool:
"""Juge si la réponse est suffisamment technique/factuelle pour être indexée."""
prompt_superviseur = f"""Tu es un bibliothécaire strict. Évalue si cet échange mérite d'être conservé.
Règle 1 : Si la réponse contient des infos factuelles, du code, des règles ou des configurations techniques, réponds UNIQUEMENT "OUI".
Règle 2 : Si c'est conversationnel, vague, ou une erreur, réponds UNIQUEMENT "NON".
Question : {prompt_utilisateur}
Réponse : {reponse_ia[:800]}
"""
try:
reponse = requests.post("http://172.19.0.1:11434/api/generate", json={
"model": "llama3.1", # Modèle rapide local via Ollama
"prompt": prompt_superviseur,
"stream": False
}, timeout=10).json()
return "OUI" in reponse.get("response", "").strip().upper()
except Exception:
return False
def check_and_update_resource_quota(utilisateur_nom, role_utilisateur):
quota_file = "/DATA/AppData/MULTI-IA-AETHAS38/Utilisateurs/quotas_ressources.json"
today = str(date.today())
limites = {"super admin": 10, "admin": 10, "vip": 5, "famille": 5, "standard": 3}
limite_max = limites.get(role_utilisateur.lower(), 3)
try:
if os.path.exists(quota_file):
with open(quota_file, "r") as f: data = json.load(f)
else: data = {}
except: data = {}
if data.get("date") != today: data = {"date": today, "users": {}}
user_uses = data["users"].get(utilisateur_nom, 0)
if user_uses >= limite_max: return False
data["users"][utilisateur_nom] = user_uses + 1
os.makedirs(os.path.dirname(quota_file), exist_ok=True)
with open(quota_file, "w") as f: json.dump(data, f)
return True
async def handle_chat_request(request, session):
start_time = time.time()
@@ -18,90 +63,155 @@ async def handle_chat_request(request, session):
prompt = form.get("prompt", "")
chat_id = form.get("chat_id", str(uuid.uuid4()))
files = form.getlist("files")
session['current_chat_id'] = chat_id
mode_payant = form.get("mode_payant") == "on"
mode_multi_ia = form.get("mode_multi_ia") == "on"
mode_education = form.get("mode_education") == "on"
media_mode = form.get("media_mode", "texte")
validation_acquise = form.get("validation_acquise") == "true"
choix_utilisateur = form.get("choix_utilisateur", "aucun")
if choix_utilisateur != "aucun":
log_event("INFO", "BILLING", f"Décision utilisateur face au pop-up : {choix_utilisateur}")
if choix_utilisateur == "gratuit_force":
mode_payant = False
session["current_chat_id"] = chat_id
history = load_history()
# Identification temporaire de l'utilisateur (en attendant le module de connexion complet)
utilisateur_nom = session.get('user', 'xavier')
utilisateur_vip = utilisateur_nom.lower() in ["xavier", "céline", "anaëlle", "renan", "audric"]
utilisateur_nom = session.get("user", "xavier")
role_utilisateur = "Super Admin" if utilisateur_nom.lower() == "xavier" else ("VIP" if utilisateur_nom.lower() in ["céline", "anaëlle", "renan", "audric"] else "standard")
ip_client = request.client.host if request.client else "Inconnue"
cpu_usage = psutil.cpu_percent(interval=0.1)
ram_usage = psutil.virtual_memory().percent
if cpu_usage > 60.0 or ram_usage > 60.0:
if not check_and_update_resource_quota(utilisateur_nom, role_utilisateur):
return Div(H3("⚠️ Limite de requêtes atteintes", style="color:#ff9800;"), P(f"Serveur très sollicité (>60%). Quota épuisé."), style="background:#333; padding:15px; border-radius:10px; border: 1px solid #ff9800;")
est_illicite, raison = analyser_intention_illicite(prompt)
if est_illicite:
sanction = appliquer_sanction_et_archiver(utilisateur_nom, prompt, raison, ip_client)
return Div(H3("⚠️ Avertissement de Sécurité"), P(f"Sanction : {sanction}"), style="color:red; background:#300; padding:15px; border-radius:10px;")
# Extraction éphémère en RAM
file_summary = []
file_names = []
for f in files[:10]:
if f and f.filename:
file_names.append(f.filename)
content = await f.read()
file_summary.append(f"\n--- Fichier: {f.filename} ---\n{content.decode('utf-8', errors='ignore')}")
file_summary.append(f"\n--- Fichier : {f.filename} ---\n{content.decode('utf-8', errors='ignore')}")
liste_fichiers_str = ', '.join(file_names) if file_names else 'Aucun fichier'
log_event("INFO", "SYSTEM", f"Requête reçue. Fichiers joints : {liste_fichiers_str}")
liste_fichiers_str = ", ".join(file_names) if file_names else "Aucun fichier"
log_event("INFO", "SYSTEM", f"Requête reçue (Mode: {media_mode.upper()} / Payant: {mode_payant})")
# L'IA Local (Qwen) orchestre la décision ! Focus sur l'efficience.
sec_prompt = f"""Tu es l'Orchestrateur système. Analyse la requête et choisis le meilleur fournisseur IA :
- "gemini" : (OBLIGATOIRE si fichiers joints), rédaction complexe, comparaisons ou recherches web.
- "openrouter_chat" : requêtes générales, texte rapide, traduction, bureautique (Modèles gratuits).
- "openrouter_code" : programmation, scripts, mathématiques, logique (Modèles gratuits).
- "local" : salutations basiques, questions très simples.
Renvoie STRICTEMENT un JSON : {{"intention": "Résumé...", "fournisseur": "gemini|openrouter_chat|openrouter_code|local"}}
Requête : {prompt}"""
try:
texte_routeur, _, _, _ = interroger_local_ollama(sec_prompt, "qwen2.5:3b")
decision = nettoyer_decision_router(texte_routeur)
except Exception:
decision = {"intention": "Analyse de secours", "fournisseur": "gemini"}
# --- AIGUILLAGE MULTIMÉDIA ---
if media_mode in ["image", "video"]:
reponse, couleur, fournisseur_nom, modele, trace_route = router_media(prompt, type_media=media_mode, premium=mode_payant, fichiers=file_names)
intention = f"Génération {media_mode.capitalize()}"
thematique_rag = "aucun"
# --- AIGUILLAGE TEXTE (CLASSIQUE) ---
else:
sec_prompt = f"""Tu es l'Orchestrateur système. Analyse la requête et choisis le meilleur fournisseur IA..."""
try:
texte_routeur, _, _, _ = interroger_local_ollama(sec_prompt)
decision = nettoyer_decision_router(texte_routeur)
except:
decision = {"intention": "Analyse", "fournisseur": "openrouter_chat", "theme_rag": "aucun"}
intention = decision.get("intention", "Analyse")
fournisseur = decision.get("fournisseur", "openrouter_chat").lower()
thematique_rag = decision.get("theme_rag", "aucun").lower()
if mode_payant or mode_multi_ia:
fournisseur = "openrouter_code"
intention += " (API Premium/Multi)"
if file_names:
fournisseur = "gemini"
if mode_education:
fournisseur = "openrouter_chat"
thematique_rag = "Education_Nationale"
intention = "Éducation Nationale " + ("(Payant)" if mode_payant else "(IA Gratuite)")
instruction_edu = "[SYSTÈME] RÈGLE ÉDUCATION NATIONALE : Agis comme un expert de l'Éducation Nationale française. Pédagogie stricte, respect du B.O.\n\n"
prompt = instruction_edu + prompt
contexte_rag = ""
if thematique_rag != "aucun":
contexte_rag = rechercher_contexte_rag(prompt, categorie=thematique_rag)
urls = re.findall(r"https?://[^\s]+", prompt)
if any(m in prompt.lower() for m in ["cherche", "recherche", "sur le web"]):
urls.extend(rechercher_sur_google(prompt))
web_text = extraire_contenu_urls(list(set(urls))) if urls else ""
intention = decision.get('intention', 'Analyse')
fournisseur = decision.get('fournisseur', 'openrouter_chat').lower()
full_prompt = prompt + ("\n" + "\n".join(file_summary) if file_summary else "")
if contexte_rag: full_prompt += f"\n\n[Contexte RAG :\n{contexte_rag}]"
if web_text: full_prompt += f"\n\n[Contexte web :\n{web_text}]"
# Sécurité Absolue Fichiers -> Gemini
if file_names:
fournisseur = "gemini"
intention += " (Forcé Gemini via Sécurité Fichiers)"
if "openrouter" in fournisseur:
modele_estime = "anthropic/claude-3.5-sonnet" if mode_payant else "cohere/north-mini-code:free"
if mode_payant and not validation_acquise:
from modules.billing_manager import estimer_cout_facture
_, prix_estime, _ = estimer_cout_facture(full_prompt, modele_estime, rang_utilisateur=role_utilisateur, multi_ia=mode_multi_ia)
if prix_estime > 0:
return render_popup_paiement(prix_estime, prompt, chat_id, mode_multi_ia, mode_education)
urls = re.findall(r'https?://[^\s]+', prompt)
mots_cles = ["cherche", "recherche", "sur internet", "sur le web", "sur le net", "va sur", "trouve", "site", "donne", "historique", "version"]
if any(m in prompt.lower() for m in mots_cles):
urls.extend(rechercher_sur_google(prompt))
if mode_payant and role_utilisateur.lower() not in ["super admin", "admin", "vip", "famille"]:
from modules.billing_manager import estimer_cout_facture
_, prix_estime, _ = estimer_cout_facture(full_prompt, modele_estime, rang_utilisateur=role_utilisateur, multi_ia=mode_multi_ia)
if prix_estime > obtenir_solde(utilisateur_nom):
return Div(H3("❌ Solde Insuffisant", style="color:red;"), P("Rechargez votre compte."), style="background:#300; padding:15px;")
web_text = extraire_contenu_urls(list(set(urls))) if urls else ""
full_prompt = prompt + ("\n" + "\n".join(file_summary) if file_summary else "")
if web_text:
full_prompt += f"\n\n[Contexte web extrait :\n{web_text}\nUtilise ces informations pour répondre.]"
reponse, couleur, fournisseur_nom, modele, trace_route = executer_requete_ia(
fournisseur, full_prompt, mode_payant=mode_payant, multi_ia=mode_multi_ia, chat_id=chat_id
)
if "openrouter" in fournisseur_nom.lower() and mode_payant:
verifier_et_valider_budget(full_prompt, modele, utilisateur_nom=utilisateur_nom, rang_utilisateur=role_utilisateur, multi_ia=mode_multi_ia)
# --- VÉRIFICATION FINANCIÈRE PRÉALABLE ---
if "openrouter" in fournisseur:
modele_estime = "qwen/qwen-2.5-coder-32b-instruct:free" if fournisseur == "openrouter_code" else "meta-llama/llama-3.1-8b-instruct:free"
# Déclenche une exception bloquante si le solde est insuffisant ou nécessite une validation
verifier_et_valider_budget(full_prompt, modele_estime, utilisateur_vip)
# Délégation de l'exécution au Moteur d'IA (Cascade)
reponse, couleur, fournisseur_nom, modele = executer_requete_ia(fournisseur, full_prompt)
# --- SUPERVISEUR IA EN ARRIÈRE-PLAN ---
if thematique_rag != "aucun" and reponse:
def superviseur_et_rag(p, r, t):
if evaluer_pertinence_pour_rag(p, r):
log_event("INFO", "SUPERVISEUR", f"Réponse validée. Indexation dans {t}")
auto_apprendre_rag(p, r, t)
else:
log_event("INFO", "SUPERVISEUR", "Échange ignoré pour le RAG (non pertinent).")
threading.Thread(target=superviseur_et_rag, args=(prompt, reponse, thematique_rag)).start()
if chat_id not in history:
history[chat_id] = {"title": (prompt[:30] if prompt else "Discussion") + "...", "pinned": False, "timestamp": time.time(), "messages": []}
execution_time = round(time.time() - start_time, 1)
display_msg = prompt.replace("[SYSTÈME] RÈGLE ÉDUCATION NATIONALE : Agis comme un expert de l'Éducation Nationale française. Pédagogie stricte, respect du B.O.\n\n", "")
if file_names: display_msg += f"\n\n📎 **Fichiers joints :** {', '.join(file_names)}"
display_msg = prompt
if file_names:
display_msg += f"\n\n📎 **Fichiers joints :** {', '.join(file_names)}"
display_msg += "\n> *🗑️ Note : Fichiers traités de manière éphémère en mémoire vive et détruits instantanément après analyse conformément aux règles de confidentialité.*"
id_message_unique = str(uuid.uuid4())
history[chat_id]['messages'].append({"role": "user", "content": display_msg})
history[chat_id]['messages'].append({"role": "ai", "content": reponse, "intention": intention, "fournisseur": fournisseur_nom, "modele": modele, "couleur": couleur, "execution_time": execution_time})
history[chat_id]["messages"].append({"role": "user", "content": display_msg})
history[chat_id]["messages"].append({
"role": "ai",
"content": reponse,
"intention": intention,
"fournisseur": fournisseur_nom,
"modele": modele,
"couleur": couleur,
"execution_time": execution_time,
"trace": trace_route,
"prompt_associe": prompt,
"thematique": thematique_rag,
"msg_id": id_message_unique
})
save_history(history)
log_event("INFO", fournisseur_nom, f"Réponse générée en {execution_time}s.")
return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur, execution_time), Script("renderMarkdown();"), render_sidebar()
return render_user_msg(display_msg), render_ai_msg(reponse, intention, fournisseur_nom, modele, couleur, execution_time, trace_route, prompt, thematique_rag, id_message_unique), Script("renderMarkdown();"), render_sidebar()
except Exception as e:
log_event("CRITICAL", "SYSTEM", f"Erreur critique dans handle_chat_request: {e}")
# Capture l'erreur de paiement pour avertir l'utilisateur dans l'UI
msg_erreur = str(e)
if "VALIDATION_PAIEMENT_REQUISE" in msg_erreur or "SOLDE_INSUFFISANT" in msg_erreur:
return Div(f"⚠️ Alerte Financière : {msg_erreur.split('|')[0]}", style="color:orange; background:#331a00; padding:15px; border-radius:10px;")
log_event("CRITICAL", "SYSTEM", f"Erreur : {e}")
return Div(f"Erreur système : {e}", style="color:red; background:#300; padding:15px; border-radius:10px;")
+10 -13
View File
@@ -2,26 +2,23 @@ import os
import json
import time
HISTORY_FILE = "aethas_history.json"
BASE_DIR = "/DATA/AppData/MULTI-IA-AETHAS38"
os.makedirs(BASE_DIR, exist_ok=True)
HISTORY_FILE = os.path.join(BASE_DIR, "aethas_history.json")
def load_history():
if not os.path.exists(HISTORY_FILE):
with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
json.dump({}, f)
with open(HISTORY_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump({}, f)
with open(HISTORY_FILE, 'r', encoding='utf-8') as f: return json.load(f)
def save_history(history):
with open(HISTORY_FILE, 'w', encoding='utf-8') as f:
json.dump(history, f, indent=4)
with open(HISTORY_FILE, 'w', encoding='utf-8') as f: json.dump(history, f, indent=4)
def nettoyer_vieilles_discussions():
history = load_history()
now = time.time()
un_an_en_secondes = 365 * 24 * 60 * 60
a_supprimer = [cid for cid, data in history.items() if not data.get('pinned', False) and now - data.get('timestamp', now) > un_an_en_secondes]
un_an = 365 * 24 * 60 * 60
a_supprimer = [cid for cid, data in history.items() if not data.get('pinned', False) and now - data.get('timestamp', now) > un_an]
if a_supprimer:
for cid in a_supprimer:
del history[cid]
save_history(history)
print(f"🧹 AETHAS CORE : Nettoyage auto terminé. {len(a_supprimer)} discussion(s) supprimée(s).")
for cid in a_supprimer: del history[cid]
save_history(history)
+21 -12
View File
@@ -3,25 +3,24 @@ import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
# Option de désactivation depuis l'interface (Phase Debug)
DEBUG_MAIL_ACTIVE = True
def envoyer_alerte_debug(niveau, source, message_erreur):
"""
Envoie un e-mail à l'administrateur en cas d'erreur serveur (Phase Debug).
Utilise le serveur SMTP défini dans le .env
Envoie un e-mail à l'administrateur.
Gère le chiffrement (Port 587) et le relai interne sans mot de passe (Port 25).
"""
if not DEBUG_MAIL_ACTIVE:
return
email_admin = os.getenv("ADMIN_EMAIL", "votre-email@domaine.com")
smtp_serveur = os.getenv("SMTP_SERVER")
smtp_serveur = os.getenv("SMTP_SERVER", "192.168.1.20")
smtp_port = int(os.getenv("SMTP_PORT", 587))
smtp_user = os.getenv("SMTP_USER")
smtp_pass = os.getenv("SMTP_PASS")
smtp_user = os.getenv("SMTP_USER", "")
smtp_pass = os.getenv("SMTP_PASS", "")
if not all([smtp_serveur, smtp_user, smtp_pass]):
print(f"[{niveau}] [DEBUG_MAILER] Configuration SMTP absente. Impossible d'envoyer l'alerte pour : {message_erreur}")
if not smtp_serveur:
print(f"[{niveau}] [DEBUG_MAILER] Serveur SMTP absent. Alerte annulée.")
return
sujet = f"[ALERTE {niveau}] Aethas38_Multi_IA - Erreur dans {source}"
@@ -36,22 +35,32 @@ def envoyer_alerte_debug(niveau, source, message_erreur):
{message_erreur}
</div>
<br>
<p><em>Notification automatique de l'Orchestrateur (Phase Debug)</em></p>
<p><em>Notification automatique de l'Orchestrateur</em></p>
</body>
</html>
"""
msg = MIMEMultipart()
msg['From'] = smtp_user
# Si pas d'utilisateur défini, on utilise une adresse générique pour le relai interne
msg['From'] = smtp_user if smtp_user else "orchestrateur@aethas38.duckdns.org"
msg['To'] = email_admin
msg['Subject'] = sujet
msg.attach(MIMEText(corps_html, 'html'))
try:
server = smtplib.SMTP(smtp_serveur, smtp_port)
server.starttls()
server.login(smtp_user, smtp_pass)
# Logique de routage intelligent selon le port
if smtp_port == 587:
server.starttls()
if smtp_user and smtp_pass:
server.login(smtp_user, smtp_pass)
elif smtp_port == 25:
# Sur le port 25 en réseau local, Mailu s'attend souvent à un simple relai sans LOGIN
pass
server.send_message(msg)
server.quit()
print(f"[{niveau}] Alerte e-mail envoyée avec succès à l'admin.")
except Exception as e:
print(f"ÉCHEC CRITIQUE : Impossible d'envoyer l'alerte e-mail : {e}")
+55
View File
@@ -0,0 +1,55 @@
import os
import requests
from bs4 import BeautifulSoup
from modules.logger import log_event
EDUCATION_RAG_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/RAG/Education_Nationale"
def synchroniser_programmes_officiels():
"""
Scrape ou récupère les données textuelles de référence pour alimenter
automatiquement le RAG Éducation Nationale.
"""
os.makedirs(EDUCATION_RAG_DIR, exist_ok=True)
# Exemple de source institutionnelle publique ou de structuration de base
url_reference = "https://www.education.gouv.fr/les-programmes-de-l-ecole-prime-et-du-college-1041"
try:
headers = {"User-Agent": "Aethas38-EducationBot/1.0"}
response = requests.get(url_reference, headers=headers, timeout=10)
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# Extraction propre du contenu textuel des programmes
contenu_page = ""
for paragraph in soup.find_all(['p', 'h2', 'h3', 'li']):
contenu_page += paragraph.get_text() + "\n"
if len(contenu_page.strip()) > 100:
chemin_fichier = os.path.join(EDUCATION_RAG_DIR, "programmes_officiels_socle.txt")
with open(chemin_fichier, 'w', encoding='utf-8') as f:
f.write(contenu_page)
log_event("INFO", "EDUC_RAG", "Synchronisation réussie des programmes officiels.")
return True
log_event("WARNING", "EDUC_RAG", "Impossible de joindre la source ou contenu vide.")
except Exception as e:
log_event("ERROR", "EDUC_RAG", f"Erreur lors de la synchronisation : {e}")
return False
def preparer_contexte_pedagogique(niveau: str, matiere: str, objectif: str) -> str:
"""
Prépare un prompt contextuel strict pour l'assistant pédagogique
en s'appuyant sur le RAG Éducation Nationale.
"""
from modules.rag_manager import extraire_contexte_rag
contexte_socle = extraire_contexte_rag("Education_Nationale")
prompt_systeme = (
f"Tu es un assistant pédagogique expert pour un Professeur des Écoles en France. "
f"Tu dois respecter strictement les programmes officiels de l'Éducation Nationale.\n\n"
f"Contexte institutionnel et programmes :\n{contexte_socle[:3000]}\n\n"
f"Mission demandée pour le niveau {niveau} en {matiere} : {objectif}"
)
return prompt_systeme
+17
View File
@@ -0,0 +1,17 @@
import os
from modules.logger import log_event
RAG_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/RAG/Education_Nationale"
def maj_base_education():
"""Vérifie la présence des documents pédagogiques locaux dans le RAG."""
os.makedirs(RAG_DIR, exist_ok=True)
fichiers = [f for f in os.listdir(RAG_DIR) if f.endswith(('.txt', '.md', '.pdf', '.docx'))]
if fichiers:
log_event("INFO", "SCRAPER_EDU", f"Base RAG Éducation active : {len(fichiers)} document(s) local/aux détecté(s).")
else:
log_event("WARNING", "SCRAPER_EDU", "Le dossier RAG Éducation est vide. Pensez à y déposer vos fichiers PDF/DOCX.")
if __name__ == "__main__":
maj_base_education()
+85 -8
View File
@@ -1,10 +1,87 @@
# modules/export_utils.py
import os
import json
import pandas as pd
from datetime import datetime
from modules.logger import log_event
def generer_donnees_json(contenu):
"""Prépare la structure de données pour l'export JSON."""
return {
"app": "Aethas38 Core",
"timestamp": time.time() if 'time' in globals() else None,
"content": contenu
}
DOSSIER_FINANCE = "/DATA/AppData/MULTI-IA-AETHAS38/Finances"
FICHIER_AUDIT = os.path.join(DOSSIER_FINANCE, "aethas_financial_audit.json")
def initialiser_dossier_finance():
os.makedirs(DOSSIER_FINANCE, exist_ok=True)
if not os.path.exists(FICHIER_AUDIT):
with open(FICHIER_AUDIT, 'w', encoding='utf-8') as f:
json.dump([], f)
def generer_export_comptable():
"""
Génère un Livre des Recettes Excel conforme URSSAF via Pandas.
Idéal pour le calcul direct du Chiffre d'Affaires à déclarer.
"""
initialiser_dossier_finance()
chemin_export = os.path.join(DOSSIER_FINANCE, f"Livre_Recettes_URSSAF_{datetime.now().strftime('%Y_%m')}.xlsx")
try:
with open(FICHIER_AUDIT, 'r', encoding='utf-8') as f:
donnees_brutes = json.load(f)
except Exception:
donnees_brutes = []
# Formatage strict pour la comptabilité d'auto-entrepreneur
lignes_comptables = []
if not donnees_brutes:
# Génération d'une ligne d'exemple légale si le fichier est vide
lignes_comptables.append({
"Date d'encaissement": datetime.now().strftime("%Y-%m-%d"),
"Référence (N° Facture / Chat ID)": "EXEMPLE-001",
"Client (Utilisateur)": "Exemple_Client",
"Nature de la prestation": "Services techniques IA (Prestation de services)",
"Coût API Brut HT (€)": float(transaction.get("cout_brut_api_eur", transaction.get("cout_brut_api", 0.0))),
"Marge Commerciale (€)": float(transaction.get("prix_facture_client_eur", 0.0)) - float(transaction.get("cout_brut_api_eur", 0.0)),
"Montant encaissé HT (€)": float(transaction.get("prix_facture_client_eur", transaction.get("prix_facture_client", 0.0)))
})
else:
for transaction in donnees_brutes:
cout_brut = float(transaction.get("cout_api", 0.0))
montant_facture = float(transaction.get("montant_facture", 0.0))
lignes_comptables.append({
"Date d'encaissement": transaction.get("date", datetime.now().strftime("%Y-%m-%d")),
"Référence (N° Facture / Chat ID)": transaction.get("id", "N/A"),
"Client (Utilisateur)": transaction.get("utilisateur", "Inconnu"),
"Nature de la prestation": f"Requête IA - {transaction.get('modele', 'Multi-modèle')}",
"Coût API Brut HT (€)": cout_brut,
"Marge Commerciale (€)": montant_facture - cout_brut,
"Montant encaissé HT (€)": montant_facture
})
# Création du DataFrame Pandas
df = pd.DataFrame(lignes_comptables)
# Calcul des totaux pour le TCD
total_ca = df["Montant encaissé HT (€)"].sum()
total_marge = df["Marge Commerciale (€)"].sum()
# Création du fichier Excel avec onglets multiples via Openpyxl
with pd.ExcelWriter(chemin_export, engine='openpyxl') as writer:
df.to_excel(writer, sheet_name="Livre des Recettes", index=False)
# Onglet de synthèse analytique
resume = pd.DataFrame({
"Indicateur Financier": ["Chiffre d'Affaires Total (Déclaration URSSAF)", "Coûts d'Infrastructure (API)", "Bénéfice Net Estimé"],
"Montant (€)": [total_ca, df["Coût API Brut HT (€)"].sum(), total_marge]
})
resume.to_excel(writer, sheet_name="Synthèse_Fiscale", index=False)
# Accès aux feuilles pour le formatage esthétique
workbook = writer.book
worksheet = writer.sheets["Livre des Recettes"]
worksheet.insert_rows(1)
worksheet.cell(row=1, column=1, value="AETHAS38 - Siège : Charvieu-Chavagneux (38230) - Registre des recettes")
worksheet.column_dimensions['A'].width = 18
worksheet.column_dimensions['C'].width = 20
worksheet.column_dimensions['D'].width = 45
log_event("INFO", "FINANCE", f"Export comptable généré : {chemin_export}")
return chemin_export
+21 -23
View File
@@ -1,28 +1,26 @@
import os
import logging
from logging.handlers import TimedRotatingFileHandler
from datetime import datetime
# Le fichier de destination pour les logs locaux
LOG_FILE = "aethas_logs.txt"
LOG_DIR = "/DATA/AppData/MULTI-IA-AETHAS38"
os.makedirs(LOG_DIR, exist_ok=True)
LOG_FILE = os.path.join(LOG_DIR, "aethas_logs.txt")
logger = logging.getLogger("Aethas38")
logger.setLevel(logging.INFO)
if not logger.handlers:
handler = TimedRotatingFileHandler(LOG_FILE, when="midnight", interval=1, backupCount=365, encoding="utf-8")
handler.suffix = "%Y-%m-%d"
formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
handler.setFormatter(formatter)
logger.addHandler(handler)
def log_event(niveau, source, message):
"""
Enregistre un événement dans le fichier de log local.
Déclenche l'envoi d'un e-mail si le niveau est ERROR ou CRITICAL.
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_line = f"[{timestamp}] [{niveau}] [{source}] {message}\n"
# 1. Écriture sécurisée dans le fichier de log local
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(log_line)
except Exception as e:
print(f"ÉCHEC D'ÉCRITURE LOG : Impossible d'écrire dans {LOG_FILE} : {e}")
# 2. Déclenchement de l'alerte e-mail pour la phase de Debug (Import local pour éviter l'import circulaire)
if niveau in ["ERROR", "CRITICAL"]:
try:
from modules.debug_mailer import envoyer_alerte_debug
envoyer_alerte_debug(niveau, source, message)
except Exception as mail_error:
print(f"ÉCHEC D'ENVOI E-MAIL : Impossible d'avertir l'administrateur : {mail_error}")
msg_formate = f"[{source}] {message}"
if niveau.upper() == "INFO": logger.info(msg_formate)
elif niveau.upper() == "WARNING": logger.warning(msg_formate)
elif niveau.upper() == "ERROR": logger.error(msg_formate)
elif niveau.upper() == "CRITICAL": logger.critical(msg_formate)
print(f"[{datetime.now().strftime('%H:%M:%S')}] [{niveau}] {msg_formate}")
+124
View File
@@ -0,0 +1,124 @@
import os
import json
import requests
import threading
import time
from modules.logger import log_event
CATALOGUE_FILE = "/DATA/AppData/MULTI-IA-AETHAS38/catalogue_dynamique.json"
LOCK_FILE = "/DATA/AppData/MULTI-IA-AETHAS38/updater_lock.txt"
def recuperer_modeles_hf():
"""
Récupère les modèles populaires de Hugging Face (Texte, Image, Vidéo).
Les identifiants sont préfixés par 'huggingface/' pour le routage Multi-IA.
"""
api_key = os.getenv("HUGGINGFACE_API_KEY")
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
# Catégories et nombre limite de modèles à récupérer pour garantir les plus fiables
categories = {
"text-generation": 20, # 20 LLMs texte
"text-to-image": 5, # 5 Générateurs d'images
"text-to-video": 2 # 2 Générateurs vidéo
}
hf_models = []
for tag, limit in categories.items():
url = "https://huggingface.co/api/models"
params = {
"pipeline_tag": tag,
"sort": "downloads",
"direction": -1,
"limit": limit
}
try:
resp = requests.get(url, headers=headers, params=params, timeout=15)
if resp.status_code == 200:
for m in resp.json():
# Formatage identifiable pour l'orchestrateur
hf_models.append(f"huggingface/{m['id']}")
except Exception as e:
log_event("WARNING", "UPDATER", f"Erreur de récupération HF pour {tag}: {e}")
return hf_models
def _do_update():
# --- SYSTÈME ANTI-SPAM (VERROU INTER-PROCESSUS) ---
now = time.time()
try:
if os.path.exists(LOCK_FILE):
with open(LOCK_FILE, "r") as f:
last_update = float(f.read().strip())
# Si la dernière MAJ date de moins d'une heure (3600 sec), on annule l'exécution de ce thread
if now - last_update < 3600:
return
except Exception:
pass # En cas d'erreur de lecture, on force la MAJ
# On pose le verrou avec le timestamp actuel pour bloquer les autres workers
try:
os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
with open(LOCK_FILE, "w") as f:
f.write(str(now))
except Exception as e:
log_event("WARNING", "UPDATER", f"Impossible d'écrire le verrou : {e}")
# --- FIN DU SYSTÈME ANTI-SPAM ---
ollama_host = os.getenv("OLLAMA_HOST", "http://192.168.1.20:11434")
try:
# MAJ locale Abliterated
resp_local = requests.post(
f"{ollama_host}/api/pull",
json={"name": "richardyoung/llama-3.2-3b-instruct-abliterated"},
timeout=300
)
if resp_local.status_code == 200:
log_event("INFO", "UPDATER", "Modèle local Abliterated vérifié/mis à jour.")
# MAJ DU CATALOGUE COMPLET OPENROUTER
resp_or = requests.get("https://openrouter.ai/api/v1/models", timeout=30)
modeles_gratuits = []
modeles_payants = []
if resp_or.status_code == 200:
data = resp_or.json().get("data", [])
for m in data:
pricing = m.get("pricing", {})
# S'il est 100% gratuit
if pricing.get("prompt") == "0" and pricing.get("completion") == "0":
if "nvidia/nemotron" not in m["id"].lower(): # On garde l'exclusion sécurité
modeles_gratuits.append(m["id"])
else:
# Intégration de TOUS les modèles payants
modeles_payants.append(m["id"])
else:
log_event("WARNING", "UPDATER", "Échec de récupération du catalogue OpenRouter.")
# MAJ DU CATALOGUE HUGGING FACE
hf_gratuits = recuperer_modeles_hf()
modeles_gratuits.extend(hf_gratuits)
with open(CATALOGUE_FILE, "w", encoding="utf-8") as f:
json.dump({"free_models": modeles_gratuits, "paid_models": modeles_payants}, f)
log_event("INFO", "UPDATER", f"Catalogue Global mis à jour : {len(modeles_gratuits)} gratuits (dont {len(hf_gratuits)} HF), {len(modeles_payants)} payants détectés.")
except Exception as e:
log_event("ERROR", "UPDATER", f"Erreur lors de la MAJ auto : {e}")
def trigger_update_now():
# Force la mise à jour en supprimant le verrou au préalable
if os.path.exists(LOCK_FILE):
try: os.remove(LOCK_FILE)
except: pass
threading.Thread(target=_do_update, daemon=True).start()
def update_models_background():
# Boucle de fond
while True:
_do_update()
time.sleep(7200) # Attente 2 heures
threading.Thread(target=update_models_background, daemon=True).start()
+109
View File
@@ -0,0 +1,109 @@
import os
import json
import re
from datetime import datetime
from modules.logger import log_event
from modules.ai_local import interroger_local_ollama
# On importe l'alerte mail pour prévenir l'Admin
try:
from modules.debug_mailer import envoyer_alerte_debug
except ImportError:
envoyer_alerte_debug = None
BASE_DATA_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/Utilisateurs"
# Liste des Super Admins / Admins à prévenir
ADMINS_GLOBAUX = ["xavier", "céline"]
def analyser_intention_illicite(prompt: str, ai_orchestrateur: str = "qwen2.5-coder:3b") -> tuple[bool, str]:
"""
Fait appel à l'IA locale pour analyser l'intention sans faire de zèle.
Sécurisé contre les valeurs None passées en paramètre.
"""
modele_cible = ai_orchestrateur if ai_orchestrateur else "qwen2.5-coder:3b"
# NOUVEAU PROMPT : Immunité absolue pour les jeux vidéo et la fiction
prompt_juge = f"""Tu es le Juge de Sécurité de la plateforme logicielle Aethas38.
Ta mission est d'analyser la requête et de détecter UNIQUEMENT les intentions strictement et réellement illégales (pédopornographie, vente de drogues réelles, terrorisme dans le monde réel, discours de haine).
RÈGLE ANTI-ZÈLE ET IMMUNITÉ GAMING : Tu dois OBLIGATOIREMENT laisser passer et considérer comme LÉGAL tout ce qui relève des jeux vidéo (comme Minecraft, les mods type Draconic Evolution ou Applied Energistics, KubeJS, les serveurs), de la fiction, du roleplay, ou des questions juridiques. La construction de "réacteurs", de "bombes", ou de "pièges" dans un contexte de jeu vidéo est TOTALEMENT AUTORISÉE.
Renvoie STRICTEMENT un JSON valide : {{"illegal": true ou false, "raison": "Explication très courte ou vide"}}
Requête à analyser : {prompt}"""
try:
# On interroge le modèle local sécurisé
reponse, _, _, _ = interroger_local_ollama(prompt_juge, modele_cible)
# Extraction du JSON de la réponse
match = re.search(r'\{.*\}', reponse, re.DOTALL)
if match:
decision = json.loads(match.group(0))
if decision.get("illegal") is True:
return True, decision.get("raison", "Détection par l'IA locale (Juge Qwen)")
except Exception as e:
log_event("WARNING", "MODERATOR", f"Échec de l'analyse IA (Fallback activé) : {e}")
# Fallback affiné pour ne pas bloquer les mots courants du jeu vidéo
mots_interdits = ["pédopornographie", "attentat terroriste réel", "vente de drogue illicite"]
if any(mot in prompt.lower() for mot in mots_interdits):
return True, "Détection par filtre de mots-clés (Secours)"
return False, ""
def appliquer_sanction_et_archiver(utilisateur_nom: str, prompt: str, raison: str, ip_client: str = "Inconnue") -> str:
"""
Gère les avertissements, archive la preuve sur le RAID 1 et alerte les administrateurs.
"""
user_dir = os.path.join(BASE_DATA_DIR, utilisateur_nom)
illegal_dir = os.path.join(user_dir, "Illegal")
dossier_sanctions = os.path.join(user_dir, "sanctions.json")
os.makedirs(illegal_dir, exist_ok=True)
strikes = 0
if os.path.exists(dossier_sanctions):
try:
with open(dossier_sanctions, "r", encoding="utf-8") as f:
data = json.load(f)
strikes = data.get("avertissements", 0)
except Exception:
strikes = 0
strikes += 1
sanction_actuelle = "Avertissement"
if strikes >= 10:
sanction_actuelle = "BAN DEFINITIF EN ATTENTE DE VALIDATION"
elif strikes >= 9:
sanction_actuelle = "Ban 1 mois"
elif strikes >= 6:
sanction_actuelle = "Ban 1 semaine"
elif strikes >= 3:
sanction_actuelle = "Ban 1 jour"
with open(dossier_sanctions, "w", encoding="utf-8") as f:
json.dump({"avertissements": strikes, "derniere_sanction": sanction_actuelle, "date": str(datetime.now())}, f, indent=4)
preuve_path = os.path.join(illegal_dir, f"infraction_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt")
with open(preuve_path, "w", encoding="utf-8") as f:
f.write(f"--- INFRACTION REPORTÉE ---\nUtilisateur : {utilisateur_nom}\nIP : {ip_client}\nDate : {datetime.now()}\nRaison : {raison}\n\n[PROMPT INTERCEPTÉ]\n{prompt}")
# Exclusion de l'admin s'il est lui-même l'auteur de l'infraction
admins_a_alerter = [admin for admin in ADMINS_GLOBAUX if admin.lower() != utilisateur_nom.lower()]
alerte_msg = f"Violation détectée pour {utilisateur_nom} (Avertissement n°{strikes}).\nSanction : {sanction_actuelle}\nPreuve stockée : {preuve_path}"
log_event("CRITICAL", "MODERATION", alerte_msg)
if envoyer_alerte_debug and admins_a_alerter:
envoyer_alerte_debug("CRITICAL", f"TENTATIVE ILLICITE ({utilisateur_nom})", alerte_msg)
return sanction_actuelle
def sauvegarder_decision_admin(utilisateur_cible: str, admin_juge: str, decision: str, commentaire: str):
"""
Archive la décision prise par un admin avec commentaire libre.
"""
dossier = os.path.join(BASE_DATA_DIR, utilisateur_cible, "Illegal")
os.makedirs(dossier, exist_ok=True)
fichier = os.path.join(dossier, f"decision_admin_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt")
with open(fichier, "w", encoding="utf-8") as f:
f.write(f"--- DÉCISION ADMINISTRATIVE ---\nCible : {utilisateur_cible}\nJuge Admin : {admin_juge}\nDate : {datetime.now()}\nDécision : {decision}\nCommentaire : {commentaire}\n")
log_event("INFO", "MODERATION", f"L'admin {admin_juge} a statué sur {utilisateur_cible} : {decision}")
+77
View File
@@ -0,0 +1,77 @@
import os
import json
import time
import requests
from modules.logger import log_event
CACHE_MODELS_FILE = "openrouter_models_cache.json"
CACHE_TTL_SECONDES = 86400 # Rafraîchissement automatique toutes les 24 heures
def synchroniser_catalogue_openrouter(forcer: bool = False) -> dict:
"""
Interroge l'API OpenRouter pour récupérer en direct tous les modèles,
leurs spécialités et leurs tarifs exacts (Prompt & Completion).
Met en cache le résultat localement.
"""
# Vérification du cache local
if not forcer and os.path.exists(CACHE_MODELS_FILE):
try:
with open(CACHE_MODELS_FILE, "r", encoding="utf-8") as f:
data = json.load(f)
if time.time() - data.get("derniere_synchro", 0) < CACHE_TTL_SECONDES:
return data.get("modeles", {})
except Exception as e:
log_event("WARNING", "OPENROUTER_SYNC", f"Erreur lecture cache modèles : {e}")
url = "https://openrouter.ai/api/v1/models"
headers = {
"Authorization": f"Bearer {os.getenv('OPENROUTER_API_KEY', '')}",
"HTTP-Referer": "https://aethas38.duckdns.org",
"X-Title": "Aethas38 Multi-IA"
}
try:
reponse = requests.get(url, headers=headers, timeout=10)
reponse.raise_for_status()
catalogue_brut = reponse.json().get("data", [])
catalogue_traite = {}
for item in catalogue_brut:
modele_id = item.get("id")
pricing = item.get("pricing", {})
# Conversion des prix en float (prix par token unitaire)
prix_prompt = float(pricing.get("prompt", 0.0) or 0.0)
prix_completion = float(pricing.get("completion", 0.0) or 0.0)
catalogue_traite[modele_id] = {
"nom": item.get("name", modele_id),
"contexte_max": item.get("context_length", 4096),
"est_gratuit": prix_prompt == 0.0 and prix_completion == 0.0,
"prix_token_prompt": prix_prompt,
"prix_token_completion": prix_completion
}
# Sauvegarde en cache
with open(CACHE_MODELS_FILE, "w", encoding="utf-8") as f:
json.dump({
"derniere_synchro": time.time(),
"total_modeles": len(catalogue_traite),
"modeles": catalogue_traite
}, f, indent=4)
log_event("INFO", "OPENROUTER_SYNC", f"Catalogue OpenRouter synchronisé : {len(catalogue_traite)} modèles chargés.")
return catalogue_traite
except Exception as e:
log_event("ERROR", "OPENROUTER_SYNC", f"Échec de synchronisation OpenRouter : {e}")
# En cas d'échec réseau, on tente de renvoyer le vieux cache existant
if os.path.exists(CACHE_MODELS_FILE):
with open(CACHE_MODELS_FILE, "r", encoding="utf-8") as f:
return json.load(f).get("modeles", {})
return {}
def obtenir_modeles_gratuits() -> list:
"""Renvoie uniquement la liste des slugs des modèles 100 % gratuits."""
catalogue = synchroniser_catalogue_openrouter()
return [slug for slug, info in catalogue.items() if info.get("est_gratuit", False)]
+48 -16
View File
@@ -1,22 +1,54 @@
import os
from fasthtml.common import *
def render_paypal_donation_widget():
"""Génère le bouton de don PayPal pour l'interface sidebar."""
def render_paypal_recharge_widget(montant_usd: float = 10.00):
client_id = os.getenv("PAYPAL_CLIENT_ID")
# Lecture forcée de la variable d'environnement
paypal_url = os.environ.get("PAYPAL_DONATION_LINK", "")
if not client_id:
return Div("Clé PayPal manquante dans .env", style="color: red;")
script_paypal = Script(src=f"https://www.paypal.com/sdk/js?client-id={client_id}&currency=USD")
container_id = f"paypal-button-container-{str(montant_usd).replace('.', '')}"
if not paypal_url:
return Div(id="paypal-widget-placeholder")
script_logique = Script(f"""
function chargerBoutonPayPal() {{
if (typeof paypal !== 'undefined') {{
paypal.Buttons({{
createOrder: function(data, actions) {{
return actions.order.create({{
purchase_units: [{{ amount: {{ value: '{montant_usd}' }} }}]
}});
}},
onApprove: function(data, actions) {{
return actions.order.capture().then(function(details) {{
alert('Paiement Sandbox validé par ' + details.payer.name.given_name + ' !');
// Fermer la fenêtre après paiement
document.getElementById('modal-recharge-paypal').style.display = 'none';
}});
}}
}}).render('#{container_id}');
}} else {{
setTimeout(chargerBoutonPayPal, 100);
}}
}}
chargerBoutonPayPal();
""")
# La boîte centrale de paiement
paypal_box = Div(
H3(f"Recharge de {montant_usd}$", style="color: #fff; margin-top: 0; margin-bottom: 15px; font-size: 1.2em; text-align: center;"),
Div(id=container_id, style="min-height: 150px; width: 100%;"),
Button("Fermer", type="button", onclick="document.getElementById('modal-recharge-paypal').style.display='none'", cls="btn-submit", style="background-color: #555; width: 100%; margin-top: 15px; border-radius: 8px; font-size: 0.9em;"),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #00e5ff; width: 400px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"
)
# L'arrière-plan sombre (overlay)
modal_overlay = Div(
script_paypal,
paypal_box,
script_logique,
id="modal-recharge-paypal",
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); align-items: center; justify-content: center; z-index: 10000;"
)
return Div(
A(
"☕ Faire un don",
href=paypal_url,
target="_blank",
cls="export-btn",
style="text-align: center; color: #00e5ff; border-color: #00e5ff; display: block; text-decoration: none;"
),
style="margin-top: 10px;"
)
return modal_overlay
+67
View File
@@ -0,0 +1,67 @@
import os
from fasthtml.common import *
def render_paypal_recharge_widget(montant_usd: float = 10.00):
client_id = os.getenv("PAYPAL_CLIENT_ID")
if not client_id:
return Div("Clé PayPal manquante dans .env", style="color: red;")
script_paypal = Script(src=f"https://www.paypal.com/sdk/js?client-id={client_id}&currency=USD")
container_id = f"paypal-button-container-{str(montant_usd).replace('.', '')}"
script_logique = Script(f"""
function chargerBoutonPayPal() {{
if (typeof paypal !== 'undefined') {{
paypal.Buttons({{
createOrder: function(data, actions) {{
return actions.order.create({{
purchase_units: [{{ amount: {{ value: '{montant_usd}' }} }}]
}});
}},
onApprove: function(data, actions) {{
return actions.order.capture().then(function(details) {{
alert('Paiement Sandbox validé par ' + details.payer.name.given_name + ' ! Vos crédits vont être ajoutés.');
// L'ajout de credentials: 'same-origin' est VITAL pour envoyer le cookie de session !
fetch('/api/paypal/success', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
credentials: 'same-origin',
body: JSON.stringify({{ orderID: data.orderID }})
}}).then(res => {{
if(res.ok) {{
document.getElementById('modal-recharge-paypal').style.display = 'none';
window.location.reload();
}} else {{
alert("Erreur lors de la validation serveur.");
}}
}});
}});
}}
}}).render('#{container_id}');
}} else {{
setTimeout(chargerBoutonPayPal, 100);
}}
}}
chargerBoutonPayPal();
""")
# La boîte centrale de paiement
paypal_box = Div(
H3(f"Recharge de {montant_usd}$", style="color: #fff; margin-top: 0; margin-bottom: 15px; font-size: 1.2em; text-align: center;"),
Div(id=container_id, style="min-height: 150px; width: 100%;"),
Button("Fermer", type="button", onclick="document.getElementById('modal-recharge-paypal').style.display='none'", cls="btn-submit", style="background-color: #555; width: 100%; margin-top: 15px; border-radius: 8px; font-size: 0.9em;"),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #00e5ff; width: 400px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"
)
# L'arrière-plan sombre (overlay)
modal_overlay = Div(
script_paypal,
paypal_box,
script_logique,
id="modal-recharge-paypal",
style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); align-items: center; justify-content: center; z-index: 10000;"
)
return modal_overlay
+97
View File
@@ -0,0 +1,97 @@
import os
import threading
from datetime import datetime
from modules.logger import log_event
# IMPORT DE NOTRE MOTEUR FAISS (Le nouveau cerveau)
from modules.vector_rag import rechercher_contexte_global, indexer_dossier_thematique
# Définition du chemin racine sécurisé sur ton RAID
RAG_BASE_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/RAG"
# Les thématiques pour organiser l'apprentissage
THEMATIQUES_RAG = [
"Minecraft_KubeJS",
"Juridique_Loi_Francaise",
"Serveur_Debian",
"Bureautique_Excel",
"Regles_Aethas38",
"Education_Nationale",
"Connaissances_Generales"
]
def initialiser_arborescence_rag():
"""Crée les dossiers RAG physiques sur le disque s'ils n'existent pas."""
try:
if not os.path.exists(RAG_BASE_DIR):
os.makedirs(RAG_BASE_DIR)
log_event("INFO", "RAG", "Création du dossier racine RAG.")
for theme in THEMATIQUES_RAG:
chemin_theme = os.path.join(RAG_BASE_DIR, theme)
if not os.path.exists(chemin_theme):
os.makedirs(chemin_theme)
log_event("INFO", "RAG", f"Création du sous-dossier thématique : {theme}")
except Exception as e:
log_event("ERROR", "RAG", f"Erreur lors de l'initialisation de l'arborescence : {e}")
def rechercher_contexte_rag(prompt, categorie="general"):
"""
Récupère le contenu des fichiers via la recherche sémantique FAISS + Ollama.
(Remplace l'ancienne méthode d'extraction basique).
"""
try:
# On fouille dans toute la base de connaissances et on récupère les 3 meilleurs blocs
contexte = rechercher_contexte_global(prompt, top_k=3)
return contexte
except Exception as e:
log_event("ERROR", "RAG_MANAGER", f"Erreur lors de la recherche FAISS : {e}")
return ""
def auto_apprendre_rag(prompt, reponse, thematique_simplifiee):
"""
Sauvegarde systématiquement la réponse en Markdown,
puis déclenche l'indexation FAISS pour auto-incrémenter le RAG.
"""
# Mapping élargi
mapping = {
"minecraft": "Minecraft_KubeJS",
"droit": "Juridique_Loi_Francaise",
"serveur": "Serveur_Debian",
"excel": "Bureautique_Excel",
"regles": "Regles_Aethas38",
"education_nationale": "Education_Nationale"
}
theme_reel = mapping.get(thematique_simplifiee.lower(), "Connaissances_Generales")
try:
chemin_theme = os.path.join(RAG_BASE_DIR, theme_reel)
os.makedirs(chemin_theme, exist_ok=True)
# On raccourcit le prompt pour créer un nom de fichier propre
sujet_court = "".join(c for c in prompt[:15] if c.isalnum() or c == " ").strip().replace(" ", "_")
nom_fichier = f"rag_{sujet_court}_{datetime.now().strftime('%H%M%S')}.md"
chemin_fichier = os.path.join(chemin_theme, nom_fichier)
contenu = f"## Question utilisateur\n{prompt}\n\n## Réponse de l'IA (Contexte validé)\n{reponse}\n"
# 1. Sauvegarde physique du fichier
with open(chemin_fichier, 'w', encoding='utf-8') as f:
f.write(contenu)
log_event("INFO", "RAG_MANAGER", f"Connaissance sauvegardée dans {theme_reel} ({nom_fichier}).")
# 2. AUTO-INCRÉMENTATION : Lancement de l'indexation FAISS
# On utilise un Thread (processus en arrière-plan) pour que l'indexation via Ollama
# se fasse silencieusement sans bloquer l'interface web de l'utilisateur.
tache_indexation = threading.Thread(target=indexer_dossier_thematique, args=(theme_reel,))
tache_indexation.start()
log_event("INFO", "RAG_MANAGER", f"Indexation vectorielle lancée en tâche de fond pour {theme_reel}.")
except Exception as e:
log_event("ERROR", "RAG_MANAGER", f"Erreur lors de l'auto-apprentissage : {e}")
# On s'assure que les dossiers existent dès l'import du module
initialiser_arborescence_rag()
+42
View File
@@ -0,0 +1,42 @@
from modules.logger import log_event
# Base de données simulée des rôles (sera connectée à database.py lors de la création de l'espace connexion)
ROLES_UTILISATEURS = {
"xavier": "Super Admin",
"céline": "Admin",
"anaëlle": "VIP",
"renan": "VIP",
"audric": "VIP"
}
def obtenir_role(nom_utilisateur):
"""Retourne le rôle de l'utilisateur."""
return ROLES_UTILISATEURS.get(nom_utilisateur.lower(), "Utilisateur")
def est_vip_ou_plus(nom_utilisateur):
"""Vérifie si l'utilisateur est exonéré de marge commerciale."""
role = obtenir_role(nom_utilisateur)
return role in ["Super Admin", "Admin", "VIP"]
def formater_affichage_nom(nom_utilisateur):
"""Formate l'affichage pour l'interface : 'Nom (Rôle)'."""
role = obtenir_role(nom_utilisateur)
nom_propre = nom_utilisateur.capitalize()
return f"{nom_propre} ({role})"
def verifier_droits_attribution(nom_attribueur, nouveau_role):
"""
Logique métier de sécurité :
- Le Super Admin peut tout attribuer.
- L'Admin ne peut attribuer que le rôle VIP ou Utilisateur.
"""
role_attribueur = obtenir_role(nom_attribueur)
if role_attribueur == "Super Admin":
return True
if role_attribueur == "Admin" and nouveau_role in ["VIP", "Utilisateur"]:
return True
log_event("WARNING", "SECURITY", f"Tentative d'élévation de privilège non autorisée par {nom_attribueur} pour le rôle {nouveau_role}.")
return False
+13 -5
View File
@@ -7,21 +7,29 @@ from modules.logger import log_event
LOG_FILE = "aethas_ip_logs.json"
def verifier_et_bloquer_vpn(ip_address):
# --- DÉFINITION CENTRALISÉE DES PROFILS PRIVILÉGIÉS ---
UTILISATEURS_VIP = ["xavier", "céline", "anaëlle", "renan", "audric", "admin"]
def verifier_et_bloquer_vpn(ip_address, utilisateur="visiteur"):
"""
Vérifie si une IP appartient à un VPN, un Proxy ou un Datacenter.
Utilise l'API publique gratuite blackbox.ipinfo.app (Renvoie 'Y' si VPN, 'N' si clean).
Intègre une liste blanche (Whitelist) pour les réseaux locaux et les VIP.
"""
# On ne bloque jamais les requêtes locales du serveur (localhost)
if ip_address in ["127.0.0.1", "localhost", "::1"]:
# 1. Bypass matériel : On ne bloque jamais les requêtes locales (Serveur, MeshCentral, Beszel, Gluetun)
if ip_address in ["127.0.0.1", "localhost", "::1"] or ip_address.startswith(("192.168.", "10.", "172.")):
return False
# 2. Bypass profil : Passe-droit pour les VIP et Administrateurs
if utilisateur.lower() in UTILISATEURS_VIP:
log_event("INFO", "SECURITY", f"Passe-droit VPN accordé à l'utilisateur VIP : {utilisateur} ({ip_address})")
return False
try:
# Requête ultra-légère, timeout court pour ne pas ralentir le site
reponse = requests.get(f"https://blackbox.ipinfo.app/lookup/{ip_address}", timeout=3)
if reponse.text.strip() == "Y":
log_event("WARNING", "SECURITY", f"Tentative de connexion VPN bloquée pour l'IP : {ip_address}")
return True # True = C'est un VPN, il faut bloquer
return True # True = C'est un VPN public/datacenter, il faut bloquer
except requests.RequestException as e:
# En cas de panne de l'API de vérification, on laisse passer pour ne pas bloquer les utilisateurs légitimes
log_event("ERROR", "SECURITY", f"Erreur lors de la vérification VPN pour {ip_address}: {e}")
+76
View File
@@ -0,0 +1,76 @@
import os
import shutil
import requests
import psutil
def obtenir_infos_serveur():
# Espace disque racine /
total, used, free = shutil.disk_usage("/")
# Recherche spécifique de la partition RAID /DATA ou du périphérique md
data_stats = {}
try:
partitions = psutil.disk_partitions(all=True)
point_montage_cible = None
# On cherche en priorité un montage sur /DATA ou un device contenant 'md'
for p in partitions:
if p.mountpoint == "/DATA" or "md" in p.device:
point_montage_cible = p.mountpoint
break
if point_montage_cible:
dt, du, df = shutil.disk_usage(point_montage_cible)
data_stats = {
"total_gb": round(dt / (2**30), 2),
"utilise_gb": round(du / (2**30), 2),
"libre_gb": round(df / (2**30), 2),
"pourcentage": round((du / dt) * 100, 1)
}
elif os.path.exists("/DATA"):
dt, du, df = shutil.disk_usage("/DATA")
data_stats = {
"total_gb": round(dt / (2**30), 2),
"utilise_gb": round(du / (2**30), 2),
"libre_gb": round(df / (2**30), 2),
"pourcentage": round((du / dt) * 100, 1)
}
except Exception:
if os.path.exists("/DATA"):
dt, du, df = shutil.disk_usage("/DATA")
data_stats = {
"total_gb": round(dt / (2**30), 2),
"utilise_gb": round(du / (2**30), 2),
"libre_gb": round(df / (2**30), 2),
"pourcentage": round((du / dt) * 100, 1)
}
# État du RAID logiciel (/proc/mdstat)
raid_status = "RAID non actif ou non détecté"
if os.path.exists("/proc/mdstat"):
try:
with open("/proc/mdstat", "r") as f:
raid_status = f.read().strip()
except Exception as e:
raid_status = f"Erreur de lecture mdstat : {e}"
# État de l'Agent Vectoriel (Ollama)
agent_status = "Injoignable"
try:
resp = requests.get("http://172.19.0.1:11434/api/tags", timeout=2)
if resp.status_code == 200:
agent_status = "Actif (Ollama & Embeddings en ligne)"
else:
agent_status = f"Répond (Code HTTP {resp.status_code})"
except Exception:
agent_status = "Hors ligne / Injoignable"
return {
"disque_total_gb": round(total / (2**30), 2),
"disque_utilise_gb": round(used / (2**30), 2),
"disque_libre_gb": round(free / (2**30), 2),
"pourcentage_utilise": round((used / total) * 100, 1),
"data_stats": data_stats,
"raid_status": raid_status,
"agent_status": agent_status
}
+44 -33
View File
@@ -11,9 +11,11 @@ def get_theme_hdrs():
Link(rel="stylesheet", href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"),
Link(rel="icon", type="image/svg+xml", href=f"data:image/svg+xml;utf8,{svg_favicon}"),
Style("""
:root { --bg-dark: #121212; --bg-panel: #1e1e24; --accent-blue: #00e5ff; --accent-purple: #b300ff; --text-main: #e0e0e0; }
:root { --bg-dark: #121212; --bg-panel: #1e1e24; --accent-blue: #00e5ff; --accent-purple: #b300ff; --text-main: #f8fafc; }
body { background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, sans-serif; margin: 0; display: flex; height: 100vh; overflow: hidden; box-sizing: border-box; }
.sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
/* --- SIDEBAR CLASSIQUE --- */
.sidebar { width: 280px; background-color: #0a0a0c; border-right: 1px solid #333; padding: 20px; display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; transition: left 0.3s ease; }
.sidebar-top { flex-shrink: 0; }
.sidebar-history { flex: 1; overflow-y: auto; margin-top: 15px; margin-bottom: 15px; }
.sidebar-footer { flex-shrink: 0; border-top: 1px solid #333; padding-top: 15px; text-align: center; }
@@ -27,50 +29,73 @@ def get_theme_hdrs():
.history-link { flex: 1; text-decoration: none; color: inherit; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; padding-right: 10px; }
.history-btn { background: none; border: none; color: #888; cursor: pointer; padding: 2px 5px; font-size: 1.1em; }
.history-btn:hover { color: var(--accent-blue); }
/* --- CONTENU CENTRAL & CONTRASTE MOBILE ACCRU --- */
.main-content { flex: 1; display: flex; flex-direction: column; height: 100vh; position: relative; box-sizing: border-box; }
.chat-container { flex: 1; padding: 20px; max-width: 1000px; margin: 0 auto; width: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding-bottom: 140px; box-sizing: border-box; }
.user-bubble { background-color: #2a2a35; padding: 15px 20px; border-radius: 15px 15px 0 15px; align-self: flex-end; max-width: 80%; border: 1px solid #444; margin-left: auto; word-break: break-word; overflow-wrap: break-word; }
.ai-bubble { background-color: var(--bg-panel); padding: 15px 20px; border-radius: 15px 15px 15px 0; align-self: flex-start; max-width: 90%; width: 100%; border: 1px solid #333; word-break: break-word; overflow-wrap: break-word; }
.msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #333; padding-top: 10px; justify-content: flex-end; flex-wrap: wrap; }
.msg-action-btn { background: transparent; border: 1px solid #444; color: #888; border-radius: 15px; padding: 5px 12px; cursor: pointer; font-size: 0.85em; transition: 0.2s; }
.chat-container { flex: 1; padding: 20px; max-width: 1000px; margin: 0 auto; width: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; padding-bottom: 220px; box-sizing: border-box; }
.user-bubble { background-color: #2a2a35; color: #ffffff; padding: 15px 20px; border-radius: 15px 15px 0 15px; align-self: flex-end; max-width: 80%; border: 1px solid #555; margin-left: auto; word-break: break-word; overflow-wrap: break-word; }
/* BULLE IA : Contraste maximal forcé pour le confort de lecture mobile */
.ai-bubble { background-color: var(--bg-panel); color: #ffffff !important; padding: 15px 20px; border-radius: 15px 15px 15px 0; align-self: flex-start; max-width: 90%; width: 100%; border: 1px solid #444; word-break: break-word; overflow-wrap: break-word; }
.markdown-rendered, .markdown-rendered p, .markdown-rendered li, .markdown-rendered span, .markdown-rendered strong { color: #ffffff !important; }
.msg-actions { display: flex; gap: 10px; margin-top: 15px; border-top: 1px dashed #444; padding-top: 10px; justify-content: flex-end; flex-wrap: wrap; }
.msg-action-btn { background: transparent; border: 1px solid #555; color: #ccc; border-radius: 15px; padding: 5px 12px; cursor: pointer; font-size: 0.85em; transition: 0.2s; }
.msg-action-btn:hover { background: #2a2a35; color: var(--accent-blue); border-color: var(--accent-blue); }
.code-header { background: #16161a; padding: 8px 15px; display: flex; justify-content: space-between; align-items: center; border-radius: 8px 8px 0 0; font-family: monospace; font-size: 0.85em; border: 1px solid #333; border-bottom: none; }
.code-actions-group { display: flex; gap: 8px; }
.code-header button { background: #2a2a35; border: 1px solid #444; color: #e0e0e0; cursor: pointer; padding: 4px 10px; border-radius: 5px; font-size: 0.85em; transition: 0.2s; }
.code-header button:hover { background: var(--accent-blue); color: black; border-color: var(--accent-blue); }
.markdown-rendered pre { margin-top: 0; border-radius: 0 0 8px 8px; border: 1px solid #333; padding: 15px; overflow-x: auto; background-color: #0d0d0f !important; }
.input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; box-sizing: border-box; }
.input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 24px; border: 1px solid #444; padding: 10px 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; }
/* --- NOUVEAU LAYOUT ZONE DE SAISIE --- */
.input-area { position: absolute; bottom: 0; width: 100%; padding: 20px; background: linear-gradient(0deg, #121212 85%, transparent); display: flex; justify-content: center; box-sizing: border-box; z-index: 10;}
.input-container-box { width: 100%; max-width: 900px; background: #1e1e24; border-radius: 20px; border: 1px solid #444; padding: 15px; box-sizing: border-box; display: flex; flex-direction: column; gap: 10px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
.textarea-wrapper { display: flex; align-items: flex-end; gap: 10px; width: 100%; }
.options-row { display: flex; gap: 20px; justify-content: center; border-top: 1px solid #333; padding-top: 10px; flex-wrap: wrap; }
#file-preview-list { display: flex; flex-wrap: wrap; gap: 6px; max-height: 80px; overflow-y: auto; padding-bottom: 5px; }
.file-pill { background: #2a2a35; border: 1px solid #555; color: var(--accent-blue); padding: 5px 12px; border-radius: 12px; font-size: 0.8em; display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
.input-row { display: flex; align-items: flex-end; gap: 10px; width: 100%; }
.input-field { background: transparent; border: none; color: white; resize: none; flex: 1; font-size: 1em; height: 36px; max-height: 140px; overflow-y: hidden; padding: 8px; font-family: inherit; box-sizing: border-box; line-height: 20px; }
.input-field:focus { outline: none; }
.file-btn { background: #2a2a35; border: 1px solid #444; color: var(--accent-blue); width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.3em; text-decoration: none; flex-shrink: 0; }
.btn-submit { background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple)); color: white; border: none; width: 36px !important; height: 36px !important; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 1.1em; flex-shrink: 0; }
/* LOADER HTMX SURÉLEVÉ ET CENTRÉ (Flotte au-dessus du texte) */
.htmx-indicator { display: none; opacity: 0; transition: opacity 0.3s ease-in; position: absolute; bottom: 140px; left: 50%; transform: translateX(-50%); z-index: 9999; }
/* LOADER HTMX SURÉLEVÉ */
.htmx-indicator { display: none; opacity: 0; transition: opacity 0.3s ease-in; position: absolute; bottom: 160px; left: 50%; transform: translateX(-50%); z-index: 9999; }
.htmx-request.htmx-indicator { display: flex !important; opacity: 1; }
.htmx-request .htmx-indicator { display: flex !important; opacity: 1; }
.tech-loader-container { display: flex; align-items: center; gap: 15px; color: var(--accent-blue); font-style: italic; background: #1e1e24; padding: 15px 25px; border-radius: 30px; border: 1px dashed var(--accent-blue); box-shadow: 0 5px 20px rgba(0,229,255,0.2); font-size: 0.9em; white-space: nowrap; }
.tech-spinner { width: 20px; height: 20px; border: 3px solid rgba(0, 229, 255, 0.2); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* --- RESPONSIVE MOBILE & SIDEBAR --- */
.mobile-header { display: none; background: #0a0a0c; padding: 10px 20px; align-items: center; justify-content: space-between; border-bottom: 1px solid #333; width: 100%; box-sizing: border-box; flex-shrink: 0;}
.hamburger-btn { background: none; border: none; color: var(--accent-blue); font-size: 1.8em; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 5px; }
.hamburger-btn:focus { outline: none; }
@media (max-width: 768px) {
body { flex-direction: column; }
.sidebar { width: 100%; height: auto; max-height: 35vh; border-right: none; border-bottom: 1px solid #333; padding: 10px; }
.sidebar-history { max-height: 120px; }
.main-content { flex: 1; height: 65vh; }
.chat-container { padding-bottom: 180px; padding-left: 10px; padding-right: 10px; }
.mobile-header { display: flex; }
.sidebar { position: absolute; left: -100%; top: 0; z-index: 1000; width: 280px; height: 100vh; box-shadow: 5px 0 15px rgba(0,0,0,0.8); }
.sidebar.sidebar-open { left: 0; }
.main-content { flex: 1; height: calc(100vh - 60px); }
.chat-container { padding-bottom: 260px; padding-left: 10px; padding-right: 10px; }
.user-bubble, .ai-bubble { max-width: 95%; }
.input-area { padding: 10px; }
.options-row { justify-content: center; gap: 10px;}
}
"""),
Script(r"""
// Sac à dos virtuel global pour additionner les fichiers sans les écraser
let aethasFiles = new DataTransfer();
window.toggleSidebar = function() {
let sidebar = document.querySelector('.sidebar');
if (sidebar) {
sidebar.classList.toggle('sidebar-open');
}
};
function syncFiles() {
const fileInput = document.getElementById('file-upload');
if(fileInput) fileInput.files = aethasFiles.files;
@@ -139,12 +164,12 @@ def get_theme_hdrs():
pre.parentNode.insertBefore(header, pre);
if (typeof hljs !== 'undefined') hljs.highlightElement(code);
}
} catch(err) { console.warn("Erreur entête code", err); }
} catch(err) {}
});
});
let chat = document.getElementById('chat-history');
if (chat) chat.scrollTop = chat.scrollHeight;
} catch(e) { console.error("Erreur markdown", e); }
} catch(e) {}
}
function getSmartFilename(btn, defaultName = 'Aethas38_Reponse', extension = 'pdf') {
@@ -219,7 +244,6 @@ def get_theme_hdrs():
});
}
// Vider le sac à dos virtel une fois la requête HTMX envoyée !
document.body.addEventListener('htmx:afterRequest', function(evt) {
if(evt.detail.target.id === 'chat-history') {
aethasFiles = new DataTransfer();
@@ -229,13 +253,6 @@ def get_theme_hdrs():
});
});
function copyCodeBlock(btn) {
let pre = btn.closest('.code-header').nextElementSibling;
navigator.clipboard.writeText(pre.querySelector('code').innerText);
btn.innerText = "✅ Copié !";
setTimeout(() => btn.innerText = "📋 Copier", 2000);
}
function downloadCodeZip(btn, lang) {
let pre = btn.closest('.code-header').nextElementSibling;
let code = pre.querySelector('code').innerText;
@@ -251,12 +268,6 @@ def get_theme_hdrs():
link.download = filename.replace('.'+ext, '_snippet.zip');
link.click();
});
} else {
let blob = new Blob([code], {type: 'text/plain;charset=utf-8'});
let link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
}
}
+299 -42
View File
@@ -1,36 +1,72 @@
import os
import requests
from fasthtml.common import *
from modules.database import load_history
from modules.paypal import render_paypal_donation_widget
from modules.billing_manager import obtenir_solde
def get_openrouter_stats():
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key: return {}
try:
resp = requests.get("https://openrouter.ai/api/v1/auth/key", headers={"Authorization": f"Bearer {api_key}"}, timeout=3)
if resp.status_code == 200:
return resp.json().get("data", {})
except: pass
return {}
def render_user_msg(text):
return Div(P(text, style="margin:0; font-weight:bold; white-space: pre-wrap;"), cls="user-bubble")
# Ajout du paramètre execution_time
def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur, execution_time=None):
def render_ai_msg(text, intention, nom_fournisseur, nom_modele, couleur, execution_time=None, trace=None, prompt_associe="", thematique="general", msg_id=""):
time_badge = Span(f"⏱️ {execution_time}s", style="margin-right: 10px; color:#aaa; font-size:0.8em;") if execution_time else ""
trace_html = ""
if trace and isinstance(trace, list):
badges = [Span(t, style="font-size:0.75em; background:#222; padding:2px 6px; border-radius:4px; border:1px solid #444;") for t in trace]
trace_html = Div(Span("Route : ", style="color:#888; font-size:0.75em;"), *badges, style="display:flex; gap:5px; align-items:center; margin-bottom:8px; flex-wrap:wrap;")
encart_info = Div(
Span(f"🎯 {intention}", style="margin-right: 10px; color:#aaa; font-size:0.8em;"),
Span(f"🏢 {nom_fournisseur}", style="margin-right: 10px; color:#aaa; font-size:0.8em;"),
Span(f"🧠 {nom_modele}", style=f"margin-right: 10px; color:{couleur}; font-weight:bold; font-size:0.8em;"),
time_badge, # ⏱️ Injection du chronomètre ici
style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 5px;"
time_badge, style="border-bottom: 1px solid #333; padding-bottom: 10px; margin-bottom: 10px; display: flex; flex-wrap: wrap; gap: 5px;"
)
bouton_forcer = ""
if msg_id and prompt_associe and thematique != "aucun":
safe_prompt = prompt_associe.replace('"', '\\"').replace('\n', ' ')
safe_reponse = text.replace('"', '\\"').replace('\n', ' ')
valeurs_htmx = f'{{"prompt": "{safe_prompt}", "reponse": "{safe_reponse}", "theme": "{thematique}"}}'
bouton_forcer = Button("💾 Mémoriser",
hx_post="/api/forcer_rag",
hx_vals=valeurs_htmx,
hx_target=f"#badge-rag-{msg_id}",
hx_swap="outerHTML",
id=f"badge-rag-{msg_id}",
cls="msg-action-btn",
style="color: #4caf50; border-color: #4caf50;"
)
actions = Div(
bouton_forcer,
Button("📄 PDF", onclick="downloadResponsePDF(this)", cls="msg-action-btn", title="Télécharger en PDF"),
Button("📘 DOC", onclick="downloadResponseDOC(this)", cls="msg-action-btn", title="Télécharger en Word"),
Button("📝 TXT", onclick="downloadResponseTXT(this)", cls="msg-action-btn", title="Télécharger en Texte"),
Button("🗄️ JSON", onclick="downloadResponseJSON(this)", cls="msg-action-btn", title="Télécharger en JSON"),
Button("💬 WhatsApp", onclick="shareWhatsApp(this)", cls="msg-action-btn", title="Partager sur WhatsApp"),
Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par Email"),
Button("📧 Email", onclick="shareEmail(this)", cls="msg-action-btn", title="Envoyer par courriel"),
cls="msg-actions"
)
return Div(
trace_html,
encart_info,
Div(text, cls="markdown-raw", style="display:none;"),
Div(cls="markdown-rendered", style="line-height: 1.6;"),
actions,
cls="ai-bubble", style=f"border-left: 4px solid {couleur};"
cls="ai-bubble",
style=f"border-left: 4px solid {couleur};"
)
def render_sidebar():
@@ -39,69 +75,290 @@ def render_sidebar():
items = []
for chat_id, data in sorted_chats:
pin_icon = "📌" if data.get('pinned', False) else "📍"
items.append(
Div(
A(Span(data['title'], style="color:inherit;"), href=f"/load_chat/{chat_id}", cls="history-link"),
Button(pin_icon, cls="history-btn", hx_post=f"/pin/{chat_id}", hx_target="#sidebar-content", hx_swap="outerHTML"),
Button("✏️", cls="history-btn", hx_post=f"/rename/{chat_id}", hx_prompt="Nouveau nom :", hx_target="#sidebar-content", hx_swap="outerHTML"),
Button("🗑️", cls="history-btn", hx_post=f"/delete/{chat_id}", hx_target="#sidebar-content", hx_swap="outerHTML"),
cls="history-item", id=f"chat-item-{chat_id}"
)
)
items.append(Div(A(Span(data['title'], style="color:inherit;"), href=f"/load_chat/{chat_id}", cls="history-link"), Button(pin_icon, cls="history-btn", hx_post=f"/pin/{chat_id}", hx_target="#sidebar-content", hx_swap="outerHTML"), Button("✏️", cls="history-btn", hx_post=f"/rename/{chat_id}", hx_prompt="Nouveau nom :", hx_target="#sidebar-content", hx_swap="outerHTML"), Button("🗑️", cls="history-btn", hx_post=f"/delete/{chat_id}", hx_target="#sidebar-content", hx_swap="outerHTML"), cls="history-item", id=f"chat-item-{chat_id}"))
return Div(*items, id="sidebar-content", cls="sidebar-history", hx_swap_oob="true")
def render_main_page(chat_id):
def render_main_page(chat_id, utilisateur_nom="xavier", role="Super Admin"):
history = load_history()
messages_ui = []
if chat_id in history:
for msg in history[chat_id].get('messages', []):
if msg['role'] == 'user':
if msg['role'] == 'user':
messages_ui.append(render_user_msg(msg['content']))
else:
messages_ui.append(render_ai_msg(msg['content'], msg.get('intention', ''), msg.get('fournisseur', ''), msg.get('modele', ''), msg.get('couleur', '#333'), msg.get('execution_time', '')))
if messages_ui:
messages_ui.append(Script("setTimeout(renderMarkdown, 150);"))
else:
messages_ui.append(render_ai_msg(
msg['content'],
msg.get('intention', ''),
msg.get('fournisseur', ''),
msg.get('modele', ''),
msg.get('couleur', '#333'),
msg.get('execution_time', ''),
msg.get('trace', []),
msg.get('prompt_associe', ''),
msg.get('thematique', 'general'),
msg.get('msg_id', '')
))
if messages_ui: messages_ui.append(Script("setTimeout(renderMarkdown, 150);"))
auto_refresh_script = Script('''
let serverDown = false;
setInterval(() => {
fetch('/ping', {cache: 'no-store'})
.then(response => { if (serverDown && response.ok) { window.location.reload(); } })
.catch(() => { serverDown = true; console.log("Serveur hors ligne..."); });
}, 5000);
function checkMediaPaymentWarning() {
const mediaMode = document.querySelector('input[name="media_mode"]:checked').value;
const isPayant = document.getElementById('mode-payant-toggle').checked;
const warningDiv = document.getElementById('media-warning');
if (mediaMode === 'video' && !isPayant) {
warningDiv.style.display = 'block';
warningDiv.innerHTML = '⚠️ La génération vidéo gratuite est très limitée ou inexistante. Cochez "Payant" pour de meilleurs résultats.';
} else if (mediaMode === 'image' && !isPayant) {
warningDiv.style.display = 'block';
warningDiv.innerHTML = 'ℹ️ Mode image gratuit : Qualité standard (HuggingFace). Pour la HD, cochez "Payant".';
} else {
warningDiv.style.display = 'none';
}
}
''')
solde_perso = obtenir_solde(utilisateur_nom)
if role.lower() in ["super admin", "admin"]:
stats_or = get_openrouter_stats()
limit_rem = float(stats_or.get('limit_remaining') or 0.0)
usage_daily = float(stats_or.get('usage_daily') or 0.0)
usage_monthly = float(stats_or.get('usage_monthly') or 0.0)
badge_finance = Div(
P(f"👤 {utilisateur_nom.capitalize()} ({role})", style="color: #00e5ff; margin: 0; font-weight: bold; font-size: 0.9em;"),
P(f"💳 Solde personnel : {solde_perso:.2f} $", style="color: #00e5ff; margin: 2px 0; font-size: 0.85em; font-weight: bold;"),
P(f"🏦 Cagnotte globale : {obtenir_solde('xavier'):.2f} $", style="color: #4caf50; margin: 0; font-size: 0.8em; font-weight: bold;"),
P(f"🌐 OR Restant : {limit_rem:.4f} $", style="color: #ff9800; margin: 2px 0 0; font-size: 0.8em; font-weight: bold;"),
P(f"📊 OR Conso (Jour/Mois) : {usage_daily:.4f}$ / {usage_monthly:.4f}$", style="color: #8bc34a; margin: 0; font-size: 0.75em;"),
style="padding: 10px; background: #181818; border-radius: 6px; border: 1px solid #333;"
)
else:
badge_finance = Div(P(f"👤 {utilisateur_nom.capitalize()} ({role})", style="color: #fff; margin: 0; font-weight: bold; font-size: 0.9em;"), P(f"💳 Solde disponible : {solde_perso:.2f} $", style="color: #00e5ff; margin: 2px 0 0; font-size: 0.85em;"), style="padding: 10px; background: #181818; border-radius: 6px; border: 1px solid #333;")
return Title("Aethas38 - Core"), Body(
auto_refresh_script,
Div(
Div(
Img(src="/Logo/logo_aethas.svg", style="max-width: 80%; height: auto; display: block; margin: 0 auto 20px;"),
Div(
A("+ Nouvelle discussion", href="/new_chat", cls="new-chat-btn"),
A("💾 Exporter JSON", href="/export_json", target="_blank", cls="export-btn"),
render_paypal_donation_widget(),
cls="nav-buttons"
),
cls="sidebar-top"
# Bouton d'accès rapide au Back Office (FO -> BO)
A("⚙️ Back Office", href="/backoffice", cls="export-btn", style="background-color: #3f51b5; color: white; border-color: #3f51b5; margin-top: 5px; text-decoration: none; text-align: center; font-weight: bold;"),
# Bouton de recharger les crédits
Button("💳 Recharger des crédits", onclick="document.getElementById('modal-recharge-paypal').style.display='flex'", cls="export-btn", style="background-color: #00e5ff; color: black; border: none; margin-top: 5px; font-weight: bold; cursor: pointer;"),
# Lien de don direct basé sur le .env
A("❤️ Faire un don", href=os.getenv("PAYPAL_DONATION_LINK", "#"), target="_blank", cls="export-btn", style="background-color: #ff4081; color: white; border-color: #ff4081; margin-top: 5px; text-decoration: none; text-align: center; font-weight: bold;"),
Button("📚 Gestion RAG", hx_get="/admin/rag_modal", hx_target="body", hx_swap="beforeend", cls="export-btn", style="background-color: #2196f3; color: white; border-color: #2196f3; margin-top: 5px; cursor: pointer;") if role.lower() in ["super admin", "admin"] else "",
A("📊 Export URSSAF (Excel)", href="/admin/export_comptable", cls="export-btn", style="background-color: #4caf50; color: white; border-color: #4caf50; margin-top: 5px; text-decoration: none; text-align: center;") if role.lower() in ["super admin", "admin"] else "",
A("🚪 Déconnexion", href="/logout", cls="export-btn", style="background-color: #8b0000; color: white; border-color: #8b0000; margin-top: 5px;"),
cls="nav-buttons", style="display: flex; flex-direction: column; gap: 10px;"
), cls="sidebar-top"
),
render_sidebar(),
Div(P("👤 Admin (Xavier)", style="color: #aaa; margin: 0; font-size: 0.9em;"), cls="sidebar-footer"),
cls="sidebar"
render_sidebar(), Div(badge_finance, cls="sidebar-footer"), cls="sidebar"
),
Div(
Div(Button("☰", onclick="toggleSidebar()", cls="hamburger-btn"), Img(src="/Logo/logo_aethas.svg", style="height: 30px;"), Div(style="width: 30px;"), cls="mobile-header"),
Div(*messages_ui, id="chat-history", cls="chat-container"),
# 🔄 Ajout de l'estimation de temps dans le Loader
Div(Div(Div(cls="tech-spinner"), Span("Analyse en cours... (~5s à 10s)"), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator", style="max-width: 1000px; margin: 0 auto; width: 100%; padding: 0 20px; box-sizing: border-box; padding-bottom: 120px;"),
Div(Div(Div(cls="tech-spinner"), Span("Analyse en cours... (~5s à 10s)"), cls="tech-loader-container"), id="global-loader", cls="htmx-indicator"),
Div(
Form(
Input(type="hidden", name="chat_id", value=chat_id),
Div(
Div(
Label(Input(type="radio", name="media_mode", value="texte", checked=True, onchange="checkMediaPaymentWarning()"), "📝 Texte", cls="media-btn"),
Label(Input(type="radio", name="media_mode", value="image", onchange="checkMediaPaymentWarning()"), "🎨 Image", cls="media-btn"),
Label(Input(type="radio", name="media_mode", value="video", onchange="checkMediaPaymentWarning()"), "🎬 Vidéo", cls="media-btn"),
style="display: flex; gap: 15px; justify-content: center; margin-bottom: 10px;"
),
Div(id="media-warning", style="display:none; color: #ff9800; font-size: 0.8em; text-align: center; margin-bottom: 10px;"),
Div(id="file-preview-list"),
Div(
Label("+", _for="file-upload", cls="file-btn"),
Input(type="file", name="files", id="file-upload", multiple=True, style="display:none;", onchange="handleFileSelection(this)"),
Textarea(name="prompt", id="prompt-input", placeholder="Requête, lien ou glissez vos fichiers ici...", cls="input-field", rows="1", oninput="autoResize(this)"),
Button("↑", type="submit", cls="btn-submit"),
cls="input-row"
Button("↑", type="submit", cls="btn-submit"), cls="textarea-wrapper"
),
cls="input-container-box"
),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader",
hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='36px';",
enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;"
),
cls="input-area"
),
cls="main-content"
Div(
Label(Input(type="checkbox", name="mode_education", id="mode-education-toggle", style="margin-right: 6px; accent-color: #8bc34a; cursor: pointer;"), "🎓 Éducation", style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"),
Label(Input(type="checkbox", name="mode_payant", id="mode-payant-toggle", style="margin-right: 6px; accent-color: #00e5ff; cursor: pointer;", onchange="checkMediaPaymentWarning()"), "👑 Payant", style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"),
Label(Input(type="checkbox", name="mode_multi_ia", id="mode-multi-toggle", style="margin-right: 6px; accent-color: #d97757; cursor: pointer;"), "⚡ Multi-IA", style="color: #aaa; font-size: 0.85em; display: flex; align-items: center; cursor: pointer; white-space: nowrap;"),
cls="options-row"
), cls="input-container-box"
), hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", hx_on__after_request="this.reset(); document.getElementById('file-preview-list').innerHTML=''; document.getElementById('prompt-input').style.height='36px'; checkMediaPaymentWarning();", enctype="multipart/form-data", style="width: 100%; display: flex; justify-content: center;"
), cls="input-area"
), cls="main-content"
)
)
def render_admin_moderation_panel(utilisateur_cible):
return Div(
H3(f"⚖️ Tribunal d'administration - Dossier de : {utilisateur_cible}", style="color: #ff4444;"),
P("Loi applicable : Aucun utilisateur n'est dispensé du respect de la législation en vigueur."),
Form(
Input(type="hidden", name="cible", value=utilisateur_cible),
Div(Label("Action décidée :", style="font-weight: bold;"), Select(Option("Avertissement simple", value="Avertissement"), Option("Bannissement 1 jour", value="Ban 1J"), Option("Bannissement définitif et signalement PHAROS", value="Ban Definitif + Pharos"), name="decision", cls="input-field"), style="margin-bottom: 15px;"),
Div(Label("Commentaire (Preuve d'audit) :", style="font-weight: bold;"), Textarea(name="commentaire", rows="4", cls="input-field", placeholder="Raison..."), style="margin-bottom: 15px;"),
Button("Valider la décision et archiver", type="submit", cls="btn-submit", style="background-color: #ff4444; color: white; width: 100%;"), hx_post="/admin_decision", hx_target="#admin-result", style="background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #333;"
), Div(id="admin-result", style="margin-top: 15px; color: #00e5ff;"), cls="admin-panel"
)
def render_login_page(error=None):
error_msg = P(error, style="color: #ff4444; text-align: center; font-weight: bold;") if error else ""
return Title("Connexion - Aethas38"), Body(
Div(
Img(src="/Logo/logo_aethas.svg", style="max-width: 180px; margin: 0 auto 20px; display: block;"),
H2("Accès Restreint", style="text-align: center; color: #00e5ff;"),
error_msg,
Form(
Div(Label("Email :", style="color: #aaa; font-size: 0.9em;"), Input(type="email", name="email", placeholder="admin@mail.aethas38.duckdns.org", cls="input-field", required=True), style="margin-bottom: 15px;"),
Div(Label("Mot de passe :", style="color: #aaa; font-size: 0.9em;"), Input(type="password", name="password", placeholder="••••••••", cls="input-field", required=True), style="margin-bottom: 20px;"),
Button("Déverrouiller le système", type="submit", cls="btn-submit", style="width: 100%;"), method="post", action="/login",
), style="max-width: 400px; margin: 100px auto; padding: 30px; background: #1a1a1a; border-radius: 10px; border: 1px solid #333; box-shadow: 0 4px 15px rgba(0,229,255,0.1);"
), style="background-color: #0d0d0d; color: #fff; font-family: monospace; height: 100vh; display: flex; align-items: center; justify-content: center;"
)
def render_popup_paiement(cout_estime, prompt_sauvegarde, chat_id, multi_ia_state=False, edu_state=False):
edu_val = "on" if edu_state else "off"
return Div(
Div(
H3("⚠️ Crédits insuffisants", style="color: #ff4444; margin-top: 0;"),
P("Cette requête nécessite un modèle d'IA dont le coût dépasse votre solde actuel.", style="color: #ccc;"),
P(f"Coût estimé de la requête : {cout_estime} $", style="font-size: 1.1em; font-weight: bold; color: #fff; background: #333; padding: 10px; border-radius: 5px; text-align: center;"),
Div(
Button("Annuler la requête", type="button", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #555; width: 48%; font-size: 0.85em; border-radius: 8px;"),
Form(
Input(type="hidden", name="prompt", value=prompt_sauvegarde),
Input(type="hidden", name="chat_id", value=chat_id),
Input(type="hidden", name="mode_payant", value="off"),
Input(type="hidden", name="mode_multi_ia", value="off"),
Input(type="hidden", name="mode_education", value=edu_val),
Input(type="hidden", name="validation_acquise", value="true"),
Input(type="hidden", name="choix_utilisateur", value="gratuit_force"),
Button("Tenter en Gratuit (Local)", type="submit", onclick="document.getElementById('popup-paiement').remove();", cls="btn-submit", style="background-color: #00e5ff; width: 100%; color: black; font-size: 0.85em; border-radius: 8px;"),
hx_post="/chat", hx_target="#chat-history", hx_swap="beforeend", hx_indicator="#global-loader", style="width: 48%;"
),
style="display: flex; justify-content: space-between; margin-top: 20px; gap: 10px;"
),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #ff4444; width: 450px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"
),
id="popup-paiement",
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999;"
)
def render_rag_upload_modal():
themes = [
"Minecraft_KubeJS", "Juridique_Loi_Francaise", "Serveur_Debian",
"Bureautique_Excel", "Regles_Aethas38", "Education_Nationale", "Connaissances_Generales"
]
options = [Option(t.replace("_", " "), value=t, style="color: black;") for t in themes]
return Div(
Div(
H3("📚 Administration RAG (Base Vectorielle)", style="color: #2196f3; margin-top: 0;"),
P("Formats supportés : PDF, MD, TXT, JS, JSON, YML, CSV", style="color: #ccc; font-size: 0.9em;"),
Form(
Div(
Label("1. Choisir la thématique :", style="font-weight: bold; color: white;"),
Select(*options, name="theme", cls="input-field", style="margin-bottom: 15px;")
),
Div(
Label("2. Sélectionner les fichiers :", style="font-weight: bold; color: white;"),
Input(type="file", name="fichiers_rag", multiple=True, required=True, cls="input-field", style="margin-bottom: 15px; background: #222; padding: 10px; border: 1px dashed #2196f3; cursor: pointer;")
),
Div(
Button("Annuler", type="button", onclick="document.getElementById('rag-modal').remove();", cls="btn-submit", style="background-color: #555; width: 48%;"),
Button("Téléverser & Indexer", type="submit", cls="btn-submit", style="background-color: #2196f3; color: white; width: 48%;"),
style="display: flex; justify-content: space-between;"
),
hx_post="/api/upload_rag",
hx_target="#rag-upload-result",
enctype="multipart/form-data"
),
Div(id="rag-upload-result", style="margin-top: 15px; text-align: center; font-weight: bold;"),
style="background: #1a1a1a; padding: 25px; border-radius: 12px; border: 2px solid #2196f3; width: 500px; max-width: 90vw; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"
),
id="rag-modal",
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 9999;"
)
def render_backoffice_page(utilisateur, role, tous_les_utilisateurs, stats_serveur):
input_style = "width: 100%; padding: 10px; background: #222; border: 1px solid #444; color: #fff; border-radius: 6px; box-sizing: border-box; font-family: monospace;"
btn_style = "width: 100%; padding: 12px; background-color: #00e5ff; color: black; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; font-family: monospace; font-size: 1em;"
card_style = "background: #1a1a1a; padding: 25px; border-radius: 10px; border: 1px solid #333; margin-bottom: 20px;"
sections = [
Div(
H2(f"⚙️ Paramètres du compte - {utilisateur.capitalize()} ({role})", style="color: #00e5ff; margin-top: 0; margin-bottom: 20px; font-size: 1.4em;"),
Form(
Div(
Label("Modifier mon pseudonyme :", style="display: block; margin-bottom: 8px; color: #aaa;"),
Input(type="text", name="nouveau_pseudo", value=utilisateur, style=input_style),
style="margin-bottom: 15px;"
),
Div(
Label("Nouveau mot de passe :", style="display: block; margin-bottom: 8px; color: #aaa;"),
Input(type="password", name="nouveau_mdp", placeholder="Laisser vide si inchangé", style=input_style),
style="margin-bottom: 20px;"
),
Button("Enregistrer les modifications", type="submit", style=btn_style),
method="post", action="/backoffice/update_profile"
),
style=card_style
)
]
if role in ["Admin", "Super Admin"]:
lignes_utilisateurs = [Li(f"{pseudo} — Rôle : {data.get('role', 'Membre')}", style="margin-bottom: 8px;") for pseudo, data in tous_les_utilisateurs.items()]
sections.append(Div(
H3("👥 Gestion des utilisateurs", style="color: #4caf50; margin-top: 0;"),
Ul(*lignes_utilisateurs, style="padding-left: 20px; color: #ddd; margin: 0;"),
style=card_style
))
if role == "Super Admin" and stats_serveur:
data_st = stats_serveur.get('data_stats', {})
sections.append(Div(
H3("🖥️ Supervision Système, RAID & Agent Vectoriel", style="color: #ff9800; margin-top: 0;"),
P(f"💾 Espace disque racine : {stats_serveur['disque_utilise_gb']} Go / {stats_serveur['disque_total_gb']} Go ({stats_serveur['pourcentage_utilise']}%)", style="margin: 8px 0;"),
(P(f"🗄️ Partition RAID (/DATA) : {data_st.get('utilise_gb', 0)} Go / {data_st.get('total_gb', 0)} Go ({data_st.get('pourcentage', 0)}% utilisés, {data_st.get('libre_gb', 0)} Go libres)", style="margin: 8px 0;") if data_st else P("🗄️ Partition RAID (/DATA) : Non détectée", style="margin: 8px 0;")),
Div(
P("📡 État du RAID (mdstat) :", style="margin: 10px 0 5px 0; font-weight: bold; color: #ccc;"),
Pre(stats_serveur.get('raid_status', 'N/A'), style="font-size: 0.8em; background: #111; color: #00ffcc; padding: 12px; border-radius: 6px; overflow-x: auto; max-height: 180px; overflow-y: auto; white-space: pre-wrap; font-family: monospace; margin: 0; border: 1px solid #333;"),
style="margin-bottom: 15px;"
),
P(f"🤖 Agent Vectoriel (Ollama) : {stats_serveur.get('agent_status', 'N/A')}", style="margin: 8px 0 20px 0;"),
Hr(style="border-color: #444; margin: 20px 0;"),
H4("⚡ Actions Globales RAG", style="color: #00e5ff; font-size: 1.1em; margin-bottom: 15px;"),
Form(
Button("Forcer la vectorisation globale de tout le RAG", type="submit", style="width: 100%; padding: 12px; background-color: #e91e63; color: white; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; font-family: monospace; font-size: 0.95em;"),
method="post", action="/backoffice/forcer_vectorisation"
),
style=card_style
))
sections.append(
A("← Retour au Chat (Front Office)", href="/", style="display: block; text-align: center; background-color: #333; color: #00e5ff; border: 1px solid #00e5ff; text-decoration: none; font-weight: bold; padding: 12px; border-radius: 6px; font-family: monospace; margin-top: 10px;")
)
return Title("Back Office - Aethas38"), Body(
Div(*sections, style="max-width: 700px; margin: 30px auto; font-family: monospace; color: #fff; padding: 0 15px 50px 15px; box-sizing: border-box;"),
style="background-color: #0d0d0d; padding: 0; margin: 0; overflow-y: auto !important; height: 100vh; box-sizing: border-box;"
)
+48
View File
@@ -0,0 +1,48 @@
import os
import json
from modules.auth_manager import hasher_mot_de_passe, verifier_mot_de_passe
USERS_FILE = "/DATA/AppData/MULTI-IA-AETHAS38/users.json"
def init_users():
if not os.path.exists(USERS_FILE):
default_users = {
"xavier": {
"email": "admin@mail.aethas38.duckdns.org",
"password_hash": hasher_mot_de_passe("Xavier2026!"),
"role": "Super Admin"
},
"celine": {
"email": "cnoirard@ac-lyon.fr",
"password_hash": hasher_mot_de_passe("Celine2026!"),
"role": "Admin"
}
}
os.makedirs(os.path.dirname(USERS_FILE), exist_ok=True)
with open(USERS_FILE, 'w', encoding='utf-8') as f:
json.dump(default_users, f, indent=4)
def load_users():
init_users()
with open(USERS_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
def save_users(users):
with open(USERS_FILE, 'w', encoding='utf-8') as f:
json.dump(users, f, indent=4)
def changer_nom_utilisateur(ancien_nom, nouveau_nom):
users = load_users()
if ancien_nom in users and nouveau_nom not in users:
users[nouveau_nom] = users.pop(ancien_nom)
save_users(users)
return True
return False
def mettre_a_jour_mot_de_passe(nom_utilisateur, nouveau_mdp):
users = load_users()
if nom_utilisateur in users:
users[nom_utilisateur]["password_hash"] = hasher_mot_de_passe(nouveau_mdp)
save_users(users)
return True
return False
+183
View File
@@ -0,0 +1,183 @@
import os
import json
import pypdf
import faiss
import numpy as np
import requests
RAG_BASE_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/RAG"
VECTOR_DB_DIR = "/DATA/AppData/MULTI-IA-AETHAS38/VectorDB"
# L'IP 172.19.0.1 correspond à la passerelle Docker pour joindre Ollama sur ton NAS
OLLAMA_URL = "http://172.19.0.1:11434/api/embeddings"
EMBEDDING_MODEL = "nomic-embed-text"
def obtenir_embedding(texte):
"""Transforme un texte en vecteur mathématique via Ollama local."""
try:
reponse = requests.post(OLLAMA_URL, json={
"model": EMBEDDING_MODEL,
"prompt": texte
})
reponse.raise_for_status()
return reponse.json()["embedding"]
except Exception as e:
print(f"Erreur d'embedding avec Ollama : {e}")
return None
def indexer_dossier_thematique(thematique: str):
print(f"--- DÉBUT DE L'INDEXATION FAISS : {thematique} ---")
chemin_theme = os.path.join(RAG_BASE_DIR, thematique)
dossier_db = os.path.join(VECTOR_DB_DIR, thematique.lower())
os.makedirs(dossier_db, exist_ok=True)
chunks = []
vecteurs = []
for fichier in os.listdir(chemin_theme):
chemin_complet = os.path.join(chemin_theme, fichier)
if not os.path.isfile(chemin_complet):
continue
print(f"Extraction de : {fichier}")
texte = ""
try:
if fichier.lower().endswith('.pdf'):
reader = pypdf.PdfReader(chemin_complet)
for page in reader.pages:
texte += page.extract_text() or ""
elif fichier.lower().endswith(('.md', '.txt', '.yml', '.yaml', '.js', '.json', '.csv', '.toml', '.properties')):
with open(chemin_complet, 'r', encoding='utf-8') as f:
texte = f.read()
if len(texte) > 50:
# Découpage en blocs de 1000 caractères
taille_chunk = 1000
blocs = [texte[i:i+taille_chunk] for i in range(0, len(texte), taille_chunk)]
print(f" -> Vectorisation de {len(blocs)} blocs via Ollama...")
for bloc in blocs:
vecteur = obtenir_embedding(bloc)
if vecteur:
# On sauvegarde le texte d'un côté, et le vecteur de l'autre
chunks.append({"fichier": fichier, "texte": bloc})
vecteurs.append(vecteur)
except Exception as e:
print(f" -> Erreur sur {fichier} : {e}")
if vecteurs:
# Conversion des vecteurs pour FAISS
vecteurs_np = np.array(vecteurs).astype('float32')
dimension = vecteurs_np.shape[1]
index = faiss.IndexFlatL2(dimension)
index.add(vecteurs_np)
# Sauvegarde sur le RAID : l'index mathématique (.faiss) et le texte (.json)
faiss.write_index(index, os.path.join(dossier_db, "index.faiss"))
with open(os.path.join(dossier_db, "chunks.json"), 'w', encoding='utf-8') as f:
json.dump(chunks, f, ensure_ascii=False, indent=2)
print(f"--- SUCCÈS : {len(chunks)} blocs vectorisés et stockés dans FAISS ! ---")
else:
print("--- ÉCHEC : Aucun vecteur généré. ---")
def rechercher_contexte_vectoriel(thematique: str, question: str, top_k: int = 3):
"""Recherche les extraits de texte pertinents pour répondre à une question."""
dossier_db = os.path.join(VECTOR_DB_DIR, thematique.lower())
fichier_index = os.path.join(dossier_db, "index.faiss")
fichier_chunks = os.path.join(dossier_db, "chunks.json")
if not os.path.exists(fichier_index) or not os.path.exists(fichier_chunks):
return ""
try:
index = faiss.read_index(fichier_index)
with open(fichier_chunks, 'r', encoding='utf-8') as f:
chunks = json.load(f)
# On vectorise la question avec le même modèle Ollama
vecteur_q = obtenir_embedding(question)
if not vecteur_q:
return ""
vecteur_q_np = np.array([vecteur_q]).astype('float32')
# On demande à FAISS les K plus proches voisins (top_k)
distances, indices = index.search(vecteur_q_np, top_k)
fragments = []
for i in indices[0]:
if i < len(chunks) and i != -1:
fragments.append(chunks[i]["texte"])
if fragments:
return "\n\n[...]\n\n".join(fragments)
except Exception as e:
print(f"Erreur de recherche FAISS : {e}")
return ""
def indexer_tout_le_rag():
"""Parcourt tous les sous-dossiers de RAG_BASE_DIR et les indexe automatiquement."""
print("=== LANCEMENT DE L'INDEXATION GLOBALE ===")
if not os.path.exists(RAG_BASE_DIR):
print(f"Dossier introuvable : {RAG_BASE_DIR}")
return
for element in os.listdir(RAG_BASE_DIR):
chemin = os.path.join(RAG_BASE_DIR, element)
if os.path.isdir(chemin):
# Appelle la fonction existante pour chaque dossier trouvé
indexer_dossier_thematique(element)
print("=== INDEXATION GLOBALE TERMINÉE ===")
def rechercher_contexte_global(question: str, top_k: int = 3):
"""Cherche la réponse dans TOUTES les thématiques indexées et garde les meilleurs extraits."""
if not os.path.exists(VECTOR_DB_DIR):
return ""
vecteur_q = obtenir_embedding(question)
if not vecteur_q:
return ""
vecteur_q_np = np.array([vecteur_q]).astype('float32')
tous_fragments = []
# On fouille dans chaque dossier de la base vectorielle
for thematique in os.listdir(VECTOR_DB_DIR):
dossier_db = os.path.join(VECTOR_DB_DIR, thematique)
fichier_index = os.path.join(dossier_db, "index.faiss")
fichier_chunks = os.path.join(dossier_db, "chunks.json")
if os.path.exists(fichier_index) and os.path.exists(fichier_chunks):
try:
index = faiss.read_index(fichier_index)
with open(fichier_chunks, 'r', encoding='utf-8') as f:
chunks = json.load(f)
# Recherche dans ce dossier spécifique
distances, indices = index.search(vecteur_q_np, top_k)
# On stocke les résultats avec leur score de pertinence (distance)
for dist, i in zip(distances[0], indices[0]):
if i < len(chunks) and i != -1:
tous_fragments.append((dist, chunks[i]["texte"], thematique))
except Exception:
continue
if not tous_fragments:
return ""
# On trie TOUS les fragments trouvés pour ne garder que les (top_k) les plus pertinents, tous dossiers confondus
# (FAISS utilise la distance L2 : plus le score est petit, plus le texte correspond à la question)
tous_fragments.sort(key=lambda x: x[0])
meilleurs = tous_fragments[:top_k]
contexte_final = []
for dist, texte, theme in meilleurs:
contexte_final.append(f"[Source : Dossier {theme}]\n{texte}")
return "\n\n---\n\n".join(contexte_final)
File renamed without changes.
File renamed without changes.
File renamed without changes.
+22
View File
@@ -0,0 +1,22 @@
import os
from openai import OpenAI
from modules.logger import log_event
def interroger_huggingface(prompt_securise):
api_key = os.getenv("HF_API_KEY")
if not api_key: raise Exception("Clé API HuggingFace manquante.")
client = OpenAI(base_url="https://api-inference.huggingface.co/v1/", api_key=api_key)
modeles = ["meta-llama/Meta-Llama-3-8B-Instruct", "mistralai/Mistral-7B-Instruct-v0.3"]
for modele in modeles:
try:
resp = client.chat.completions.create(
model=modele,
messages=[{"role": "system", "content": "Réponds en français en Markdown."}, {"role": "user", "content": prompt_securise}],
max_tokens=1500
)
if resp and resp.choices:
log_event("INFO", f"HF-{modele}", "Succès.")
return resp.choices[0].message.content, "#fbbf24", "HUGGINGFACE", modele
except Exception as e:
log_event("WARNING", f"HF-{modele}", f"Erreur: {e}")
raise Exception("Tous les modèles HuggingFace ont échoué.")
File renamed without changes.
+238
View File
@@ -0,0 +1,238 @@
diff --git a/main.py b/main.py
index 6115ef5..30b8c0c 100644
--- a/main.py
+++ b/main.py
@@ -8,11 +8,10 @@ from fasthtml.common import *
from starlette.staticfiles import StaticFiles
from starlette.responses import FileResponse, Response, HTMLResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
-from modules.user_db import load_users, save_users, changer_nom_utilisateur, mettre_a_jour_mot_de_passe
-from modules.system_utils import obtenir_infos_serveur
+
from modules.theme import get_theme_hdrs
from modules.database import nettoyer_vieilles_discussions, HISTORY_FILE, load_history, save_history
-from modules.ui_components import render_main_page, render_sidebar, render_login_page, render_rag_upload_modal
+from modules.ui_components import render_main_page, render_sidebar, render_login_page, render_rag_upload_modal, render_backoffice_page
from modules.chat_handler import handle_chat_request
from modules.security import verifier_et_bloquer_vpn, journaliser_ip
from modules.auth_manager import IDENTIFIANTS_ADMIN, verifier_mot_de_passe
@@ -20,6 +19,8 @@ from modules.logger import log_event
from modules.billing_manager import charger_soldes, sauvegarder_soldes
from modules.rag_manager import auto_apprendre_rag
from modules.vector_rag import indexer_dossier_thematique
+from modules.user_db import load_users, save_users, changer_nom_utilisateur, mettre_a_jour_mot_de_passe
+from modules.system_utils import obtenir_infos_serveur

# Lancement du module d'auto-mise à jour en arrière-plan
import modules.model_updater
@@ -27,108 +28,13 @@ import modules.model_updater
dossier_media = "/DATA/AppData/MULTI-IA-AETHAS38/RAG/Media"
os.makedirs(dossier_media, exist_ok=True)

-@rt('/backoffice')
-def get(session, request):
- utilisateur = session.get('user') or request.cookies.get('aethas_user')
- if not utilisateur:
- return RedirectResponse('/login', status_code=303)
- 
- users = load_users()
- infos_user = users.get(utilisateur, {"role": "Membre"})
- role = infos_user.get("role", "Membre")
- 
- # Données spécifiques Super Admin
- stats_serveur = obtenir_infos_serveur() if role == "Super Admin" else None
- 
- return render_backoffice_page(utilisateur, role, users, stats_serveur)
-
-@rt('/backoffice/update_profile')
-async def post(request, session):
- utilisateur = session.get('user') or request.cookies.get('aethas_user')
- if not utilisateur:
- return RedirectResponse('/login', status_code=303)
- 
- form = await request.form()
- nouveau_pseudo = form.get("nouveau_pseudo")
- nouveau_mdp = form.get("nouveau_mdp")
- 
- if nouveau_pseudo and nouveau_pseudo != utilisateur:
- succes = changer_nom_utilisateur(utilisateur, nouveau_pseudo)
- if succes:
- session['user'] = nouveau_pseudo
- resp = RedirectResponse('/backoffice', status_code=303)
- resp.set_cookie("aethas_user", nouveau_pseudo, max_age=86400)
- return resp
- 
- if nouveau_mdp:
- mettre_a_jour_mot_de_passe(utilisateur, nouveau_mdp)
- 
- return RedirectResponse('/backoffice', status_code=303)
-
-# --- WIDGET PAYPAL (RECHARGE DE CRÉDITS) ---
-def render_paypal_recharge_widget(montant_usd: float = 10.00):
- client_id = os.getenv("PAYPAL_CLIENT_ID", "test")
- 
- script_paypal = Script(src=f"https://www.paypal.com/sdk/js?client-id={client_id}&currency=USD")
- container_id = f"paypal-button-container-{str(montant_usd).replace('.', '')}"
- 
- script_logique = Script(f"""
- function chargerBoutonPayPal() {{
- if (typeof paypal !== 'undefined') {{
- paypal.Buttons({{
- style: {{ layout: 'vertical', height: 35, shape: 'rect', label: 'paypal' }},
- createOrder: function(data, actions) {{
- return actions.order.create({{
- purchase_units: [{{ amount: {{ value: '{montant_usd}' }} }}]
- }});
- }},
- onApprove: function(data, actions) {{
- return actions.order.capture().then(function(details) {{
- alert('Paiement validé par ' + details.payer.name.given_name + ' ! Vos crédits vont être ajoutés.');
- 
- fetch('/api/paypal/success', {{
- method: 'POST',
- headers: {{ 'Content-Type': 'application/json' }},
- body: JSON.stringify({{ orderID: data.orderID }})
- }}).then(res => {{
- if(res.ok) {{
- document.getElementById('modal-recharge-paypal').style.display = 'none';
- window.location.reload();
- }} else {{
- alert("Erreur lors de la validation serveur.");
- }}
- }});
- }});
- }}
- }}).render('#{container_id}');
- }} else {{
- setTimeout(chargerBoutonPayPal, 100);
- }}
- }}
- chargerBoutonPayPal();
- """)
-
- paypal_box = Div(
- H4(f"Recharge : {montant_usd} $", style="color: #fff; margin: 0 0 10px 0; font-size: 1.1em; text-align: center;"),
- Div(id=container_id, style="min-height: 100px; width: 100%;"),
- Button("Annuler", type="button", onclick="document.getElementById('modal-recharge-paypal').style.display='none'", 
- style="background: transparent; border: 1px solid #555; color: #aaa; width: 100%; margin-top: 5px; padding: 6px; border-radius: 6px; font-size: 0.85em; cursor: pointer;"),
- style="background: #1a1a1a; padding: 20px; border-radius: 10px; border: 1px solid #333; width: 320px; max-width: 90vw; box-shadow: 0 8px 24px rgba(0,0,0,0.9);"
- )
-
- modal_overlay = Div(
- script_paypal, paypal_box, script_logique,
- id="modal-recharge-paypal",
- style="display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.6); backdrop-filter: blur(3px); align-items: center; justify-content: center; z-index: 10000;"
- )
- return modal_overlay
-
# --- MIDDLEWARE DE SÉCURITÉ & SERVEUR MÉDIA UNIFIÉ ---
class SecurityMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
ip = request.headers.get("X-Forwarded-For", request.client.host).split(",")[0].strip()
utilisateur = request.cookies.get("aethas_user", "visiteur")

+ # --- LECTURE EN RAM ULTRA-DIRECTE PAR LE MIDDLEWARE ---
if request.url.path.startswith("/Media/"):
fname = request.url.path.replace("/Media/", "", 1)
chemin = os.path.join(dossier_media, fname)
@@ -140,10 +46,12 @@ class SecurityMiddleware(BaseHTTPMiddleware):
return HTMLResponse(f"Erreur : {e}", status_code=500)
return HTMLResponse("Fichier introuvable", status_code=404)

+ # EXCLUSION DE /Media POUR NE PAS BLOQUER LE RESTE
if not request.url.path.startswith(("/Logo", "/login", "/Media")):
if verifier_et_bloquer_vpn(ip, utilisateur):
return HTMLResponse("<h1 style='color:red; text-align:center;'>Accès refusé</h1><p style='text-align:center;'>VPN ou Proxy strictement interdit.</p>", status_code=403)

+ # Exclusion stricte du spam de logs (Silence pour /ping et /favicon)
if request.url.path not in ["/ping", "/favicon.ico"]:
journaliser_ip(ip, f"Accès à {request.url.path}")

@@ -182,6 +90,44 @@ def get(session):
resp.delete_cookie("aethas_user")
return resp

+# --- ROUTES DU BACK OFFICE & PROFIL ---
+@rt('/backoffice')
+def get(session, request):
+ utilisateur = session.get('user') or request.cookies.get('aethas_user')
+ if not utilisateur:
+ return RedirectResponse('/login', status_code=303)
+ 
+ users = load_users()
+ infos_user = users.get(utilisateur, {"role": "Membre"})
+ role = infos_user.get("role", "Membre")
+ 
+ stats_serveur = obtenir_infos_serveur() if role == "Super Admin" else None
+ 
+ return render_backoffice_page(utilisateur, role, users, stats_serveur)
+
+@rt('/backoffice/update_profile')
+async def post(request, session):
+ utilisateur = session.get('user') or request.cookies.get('aethas_user')
+ if not utilisateur:
+ return RedirectResponse('/login', status_code=303)
+ 
+ form = await request.form()
+ nouveau_pseudo = form.get("nouveau_pseudo")
+ nouveau_mdp = form.get("nouveau_mdp")
+ 
+ if nouveau_pseudo and nouveau_pseudo != utilisateur:
+ succes = changer_nom_utilisateur(utilisateur, nouveau_pseudo)
+ if succes:
+ session['user'] = nouveau_pseudo
+ resp = RedirectResponse('/backoffice', status_code=303)
+ resp.set_cookie("aethas_user", nouveau_pseudo, max_age=86400)
+ return resp
+ 
+ if nouveau_mdp:
+ mettre_a_jour_mot_de_passe(utilisateur, nouveau_mdp)
+ 
+ return RedirectResponse('/backoffice', status_code=303)
+
# --- ROUTES PROTÉGÉES ---
@rt('/')
def get(session, request):
@@ -192,11 +138,7 @@ def get(session, request):
session['user'] = utilisateur
if 'current_chat_id' not in session or not session['current_chat_id']:
session['current_chat_id'] = str(uuid.uuid4())
- 
- main_page = render_main_page(session['current_chat_id'], utilisateur_nom=session['user'])
- paypal_modal = render_paypal_recharge_widget(10.00)
- 
- return paypal_modal, main_page
+ return render_main_page(session['current_chat_id'], utilisateur_nom=session['user'])

@rt('/chat')
async def post(request, session):
@@ -245,18 +187,14 @@ def post(cid: str):
if cid_str in h: del h[cid_str]; save_history(h)
return render_sidebar()

-@rt('/api/paypal/success')
-async def post(request):
- data = await request.json()
- order_id = data.get("orderID")
- log_event("INFO", "FINANCE", f"Recharge PayPal validée - OrderID: {order_id}")
- return {"status": "success"}
-
+# --- NOUVELLE ROUTE POUR LE RAG (BOUTON MANUEL) ---
@rt('/api/forcer_rag')
def post(prompt: str, reponse: str, theme: str):
+ """Intercepte la demande manuelle, lance l'indexation et renvoie un badge vert."""
auto_apprendre_rag(prompt, reponse, theme)
return Span("✅ Indexé", cls="msg-action-btn", style="background-color: #4caf50; color: white; border: none; cursor: default;")

+# --- NOUVELLES ROUTES POUR L'UPLOAD MODAL DU RAG ---
@rt('/admin/rag_modal')
def get(session):
if session.get('user', '') != 'xavier': 
@@ -295,6 +233,7 @@ async def post(request, session):

from modules.export_utils import generer_export_comptable

+# --- NOUVELLE ROUTE POUR L'EXPORT COMPTABLE EXCEL ---
@rt('/admin/export_comptable')
def get(session):
if session.get('user', '') != 'xavier': 
+8 -13
View File
@@ -1,23 +1,18 @@
# --- Serveur & Interface ---
python-fasthtml
uvicorn
python-dotenv
# --- Moteurs IA ---
openai
google-generativeai
# --- Utilitaires & Web ---
google-genai
requests
beautifulsoup4
# --- Comptabilité & Export ---
pandas
openpyxl
pandas==2.2.0
openpyxl==3.1.2
reportlab
# --- Sécurité & Monitoring (AJOUTS 18/08/2026) ---
psutil
cryptography
bcrypt
tiktoken
tiktoken
pypdf==4.1.0
python-docx==1.1.0
faiss-cpu==1.8.0
numpy==1.26.4
+304
View File
@@ -0,0 +1,304 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
ESC-j * Forward one file line (or _N file lines).
ESC-k * Backward one file line (or _N file lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
ESC-b * Backward one window, but don't stop at beginning-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
ESC-f Like F but ring the bell when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
Search is case-sensitive unless changed with -i or -I.
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< HOME * Go to first line in file (or line _N).
G > ESC-> END * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> * Mark the top line (or line _N) with <letter>.
M_<_l_e_t_t_e_r_> * Mark the bottom line (or line _N) with <letter>.
'_<_l_e_t_t_e_r_> * Go to a previously marked position (put on position _N).
'' * Go to the previous position.
^X^X * Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after full screen movement.
-W ........ --HILITE-UNREAD
Highlight first new line after any movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--autosave=[_m_/_!_*]
Actions which cause the history file to be saved.
--emouse=[_v_s_c_r_o_l_l,_v_d_r_a_g,_h_s_c_r_o_l_l,_h_d_r_a_g,_l_c_l_i_c_k,_r_c_l_i_c_k]
Enable mouse features.
--end-prompt=[_s_t_r_i_n_g]
String to be printed after erasing the prompt.
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--form-feed
Stop scrolling when a form feed character is reached.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--hilite-target
Highlight the target line.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse clicking and vertical scrolling.
--no-edit-warn
Don't warn when using v command on a file opened via LESSOPEN.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-paste
Ignore pasted input.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--past-eof
Scrolling commands continue past end of file.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
+335
View File
@@ -0,0 +1,335 @@
SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS
Commands marked with * may be preceded by a number, _N.
Notes in parentheses indicate the behavior if _N is given.
A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K.
h H Display this help.
q :q Q :Q ZZ Exit.
---------------------------------------------------------------------------
MMOOVVIINNGG
e ^E j ^N CR * Forward one line (or _N lines).
y ^Y k ^K ^P * Backward one line (or _N lines).
ESC-j * Forward one file line (or _N file lines).
ESC-k * Backward one file line (or _N file lines).
f ^F ^V SPACE * Forward one window (or _N lines).
b ^B ESC-v * Backward one window (or _N lines).
z * Forward one window (and set window to _N).
w * Backward one window (and set window to _N).
ESC-SPACE * Forward one window, but don't stop at end-of-file.
ESC-b * Backward one window, but don't stop at beginning-of-file.
d ^D * Forward one half-window (and set half-window to _N).
u ^U * Backward one half-window (and set half-window to _N).
ESC-) RightArrow * Right one half screen width (or _N positions).
ESC-( LeftArrow * Left one half screen width (or _N positions).
ESC-} ^RightArrow Right to last column displayed.
ESC-{ ^LeftArrow Left to first column.
F Forward forever; like "tail -f".
ESC-F Like F but stop when search pattern is found.
ESC-f Like F but ring the bell when search pattern is found.
r ^R ^L Repaint screen.
R Repaint screen, discarding buffered input.
---------------------------------------------------
Default "window" is the screen height.
Default "half-window" is half of the screen height.
---------------------------------------------------------------------------
SSEEAARRCCHHIINNGG
/_p_a_t_t_e_r_n * Search forward for (_N-th) matching line.
?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line.
n * Repeat previous search (for _N-th occurrence).
N * Repeat previous search in reverse direction.
ESC-n * Repeat previous search, spanning files.
ESC-N * Repeat previous search, reverse dir. & spanning files.
^O^N ^On * Search forward for (_N-th) OSC8 hyperlink.
^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink.
^O^L ^Ol Jump to the currently selected OSC8 hyperlink.
ESC-u Undo (toggle) search highlighting.
ESC-U Clear search highlighting.
&_p_a_t_t_e_r_n * Display only matching lines.
---------------------------------------------------
Search is case-sensitive unless changed with -i or -I.
A search pattern may begin with one or more of:
^N or ! Search for NON-matching lines.
^E or * Search multiple files (pass thru END OF FILE).
^F or @ Start search at FIRST file (for /) or last file (for ?).
^K Highlight matches, but don't move (KEEP position).
^R Don't use REGULAR EXPRESSIONS.
^S _n Search for match in _n-th parenthesized subpattern.
^W WRAP search if no match found.
^L Enter next character literally into pattern.
---------------------------------------------------------------------------
JJUUMMPPIINNGG
g < ESC-< HOME * Go to first line in file (or line _N).
G > ESC-> END * Go to last line in file (or line _N).
p % * Go to beginning of file (or _N percent into file).
t * Go to the (_N-th) next tag.
T * Go to the (_N-th) previous tag.
{ ( [ * Find close bracket } ) ].
} ) ] * Find open bracket { ( [.
ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>.
ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>.
---------------------------------------------------
Each "find close bracket" command goes forward to the close bracket
matching the (_N-th) open bracket in the top line.
Each "find open bracket" command goes backward to the open bracket
matching the (_N-th) close bracket in the bottom line.
m_<_l_e_t_t_e_r_> * Mark the top line (or line _N) with <letter>.
M_<_l_e_t_t_e_r_> * Mark the bottom line (or line _N) with <letter>.
'_<_l_e_t_t_e_r_> * Go to a previously marked position (put on position _N).
'' * Go to the previous position.
^X^X * Same as '.
ESC-m_<_l_e_t_t_e_r_> Clear a mark.
---------------------------------------------------
A mark is any upper-case or lower-case letter.
Certain marks are predefined:
^ means beginning of the file
$ means end of the file
---------------------------------------------------------------------------
CCHHAANNGGIINNGG FFIILLEESS
:e [_f_i_l_e] Examine a new file.
^X^V Same as :e.
:n * Examine the (_N-th) next file from the command line.
:p * Examine the (_N-th) previous file from the command line.
:x * Examine the first (or _N-th) file from the command line.
^O^O Open the currently selected OSC8 hyperlink.
:d Delete the current file from the command line list.
= ^G :f Print current file name.
---------------------------------------------------------------------------
MMIISSCCEELLLLAANNEEOOUUSS CCOOMMMMAANNDDSS
-_<_f_l_a_g_> Toggle a command line option [see OPTIONS below].
--_<_n_a_m_e_> Toggle a command line option, by name.
__<_f_l_a_g_> Display the setting of a command line option.
___<_n_a_m_e_> Display the setting of an option, by name.
+_c_m_d Execute the less cmd each time a new file is examined.
!_c_o_m_m_a_n_d Execute the shell command with $SHELL.
#_c_o_m_m_a_n_d Execute the shell command, expanded like a prompt.
|XX_c_o_m_m_a_n_d Pipe file between current pos & mark XX to shell command.
s _f_i_l_e Save input to a file.
v Edit the current file with $VISUAL or $EDITOR.
V Print version number of "less".
---------------------------------------------------------------------------
OOPPTTIIOONNSS
Most options may be changed either on the command line,
or from within less by using the - or -- command.
Options may be given in one of two forms: either a single
character preceded by a -, or a name preceded by --.
-? ........ --help
Display help (from command line).
-a ........ --search-skip-screen
Search skips current screen.
-A ........ --SEARCH-SKIP-SCREEN
Search starts just after target line.
-b [_N] .... --buffers=[_N]
Number of buffers.
-B ........ --auto-buffers
Don't automatically allocate buffers for pipes.
-c ........ --clear-screen
Repaint by clearing rather than scrolling.
-d ........ --dumb
Dumb terminal.
-D xx_c_o_l_o_r . --color=xx_c_o_l_o_r
Set screen colors.
-e -E .... --quit-at-eof --QUIT-AT-EOF
Quit at end of file.
-f ........ --force
Force open non-regular files.
-F ........ --quit-if-one-screen
Quit if entire file fits on first screen.
-g ........ --hilite-search
Highlight only last match for searches.
-G ........ --HILITE-SEARCH
Don't highlight any matches for searches.
-h [_N] .... --max-back-scroll=[_N]
Backward scroll limit.
-i ........ --ignore-case
Ignore case in searches that do not contain uppercase.
-I ........ --IGNORE-CASE
Ignore case in all searches.
-j [_N] .... --jump-target=[_N]
Screen position of target lines.
-J ........ --status-column
Display a status column at left edge of screen.
-k _f_i_l_e ... --lesskey-file=_f_i_l_e
Use a compiled lesskey file.
-K ........ --quit-on-intr
Exit less in response to ctrl-C.
-L ........ --no-lessopen
Ignore the LESSOPEN environment variable.
-m -M .... --long-prompt --LONG-PROMPT
Set prompt style.
-n ......... --line-numbers
Suppress line numbers in prompts and messages.
-N ......... --LINE-NUMBERS
Display line number at start of each line.
-o [_f_i_l_e] .. --log-file=[_f_i_l_e]
Copy to log file (standard input only).
-O [_f_i_l_e] .. --LOG-FILE=[_f_i_l_e]
Copy to log file (unconditionally overwrite).
-p _p_a_t_t_e_r_n . --pattern=[_p_a_t_t_e_r_n]
Start at pattern (from command line).
-P [_p_r_o_m_p_t] --prompt=[_p_r_o_m_p_t]
Define new prompt.
-q -Q .... --quiet --QUIET --silent --SILENT
Quiet the terminal bell.
-r -R .... --raw-control-chars --RAW-CONTROL-CHARS
Output "raw" control characters.
-s ........ --squeeze-blank-lines
Squeeze multiple blank lines.
-S ........ --chop-long-lines
Chop (truncate) long lines rather than wrapping.
-t _t_a_g .... --tag=[_t_a_g]
Find a tag.
-T [_t_a_g_s_f_i_l_e] --tag-file=[_t_a_g_s_f_i_l_e]
Use an alternate tags file.
-u -U .... --underline-special --UNDERLINE-SPECIAL
Change handling of backspaces, tabs and carriage returns.
-V ........ --version
Display the version number of "less".
-w ........ --hilite-unread
Highlight first new line after full screen movement.
-W ........ --HILITE-UNREAD
Highlight first new line after any movement.
-x [_N[,...]] --tabs=[_N[,...]]
Set tab stops.
-X ........ --no-init
Don't use termcap init/deinit strings.
-y [_N] .... --max-forw-scroll=[_N]
Forward scroll limit.
-z [_N] .... --window=[_N]
Set size of window.
-" [_c[_c]] . --quotes=[_c[_c]]
Set shell quote characters.
-~ ........ --tilde
Don't display tildes after end of file.
-# [_N] .... --shift=[_N]
Set horizontal scroll amount (0 = one half screen width).
--autosave=[_m_/_!_*]
Actions which cause the history file to be saved.
--emouse=[_v_s_c_r_o_l_l,_v_d_r_a_g,_h_s_c_r_o_l_l,_h_d_r_a_g,_l_c_l_i_c_k,_r_c_l_i_c_k]
Enable mouse features.
--end-prompt=[_s_t_r_i_n_g]
String to be printed after erasing the prompt.
--exit-follow-on-close
Exit F command on a pipe when writer closes pipe.
--file-size
Automatically determine the size of the input file.
--follow-name
The F command changes files if the input file is renamed.
--form-feed
Stop scrolling when a form feed character is reached.
--header=[_L[,_C[,_N]]]
Use _L lines (starting at line _N) and _C columns as headers.
--hilite-target
Highlight the target line.
--incsearch
Search file as each pattern character is typed in.
--intr=[_C]
Use _C instead of ^X to interrupt a read.
--lesskey-context=_t_e_x_t
Use lesskey source file contents.
--lesskey-src=_f_i_l_e
Use a lesskey source file.
--line-num-width=[_N]
Set the width of the -N line number field to _N characters.
--match-shift=[_N]
Show at least _N characters to the left of a search match.
--modelines=[_N]
Read _N lines from the input file and look for vim modelines.
--mouse
Enable mouse clicking and vertical scrolling.
--no-edit-warn
Don't warn when using v command on a file opened via LESSOPEN.
--no-keypad
Don't send termcap keypad init/deinit strings.
--no-histdups
Remove duplicates from command history.
--no-number-headers
Don't give line numbers to header lines.
--no-paste
Ignore pasted input.
--no-search-header-lines
Searches do not include header lines.
--no-search-header-columns
Searches do not include header columns.
--no-search-headers
Searches do not include header lines or columns.
--no-vbell
Disable the terminal's visual bell.
--redraw-on-quit
Redraw final screen when quitting.
--rscroll=[_C]
Set the character used to mark truncated lines.
--save-marks
Retain marks across invocations of less.
--search-options=[EFKNRW-]
Set default options for every search.
--show-preproc-errors
Display a message if preprocessor exits with an error status.
--past-eof
Scrolling commands continue past end of file.
--proc-backspace
Process backspaces for bold/underline.
--PROC-BACKSPACE
Treat backspaces as control characters.
--proc-return
Delete carriage returns before newline.
--PROC-RETURN
Treat carriage returns as control characters.
--proc-tab
Expand tabs to spaces.
--PROC-TAB
Treat tabs as control characters.
--status-col-width=[_N]
Set the width of the -J status column to _N characters.
--status-line
Highlight or color the entire line containing a mark.
--use-backslash
Subsequent options use backslash as escape char.
--use-color
Enables colored text.
--wheel-lines=[_N]
Each click of the mouse wheel moves _N lines.
--wordwrap
Wrap lines at spaces.
---------------------------------------------------------------------------
LLIINNEE EEDDIITTIINNGG
These keys can be used to edit text being entered
on the "command line" at the bottom of the screen.
RightArrow ..................... ESC-l ... Move cursor right one character.
LeftArrow ...................... ESC-h ... Move cursor left one character.
ctrl-RightArrow ESC-RightArrow ESC-w ... Move cursor right one word.
ctrl-LeftArrow ESC-LeftArrow ESC-b ... Move cursor left one word.
HOME ........................... ESC-0 ... Move cursor to start of line.
END ............................ ESC-$ ... Move cursor to end of line.
BACKSPACE ................................ Delete char to left of cursor.
DELETE ......................... ESC-x ... Delete char under cursor.
ctrl-BACKSPACE ESC-BACKSPACE ........... Delete word to left of cursor.
ctrl-DELETE .... ESC-DELETE .... ESC-X ... Delete word under cursor.
ctrl-U ......... ESC (MS-DOS only) ....... Delete entire line.
UpArrow ........................ ESC-k ... Retrieve previous command line.
DownArrow ...................... ESC-j ... Retrieve next command line.
TAB ...................................... Complete filename & cycle.
SHIFT-TAB ...................... ESC-TAB Complete filename & reverse cycle.
ctrl-L ................................... Complete filename, list all.
+30
View File
@@ -0,0 +1,30 @@
import os
import requests
from dotenv import load_dotenv
import json
# Charge ta clé API depuis le fichier .env
load_dotenv()
api_key = os.getenv("OPENROUTER_API_KEY")
if not api_key:
print("Erreur : Clé OPENROUTER_API_KEY introuvable dans le .env")
exit()
print("🔍 Interrogation des serveurs OpenRouter...")
try:
response = requests.get(
"https://openrouter.ai/api/v1/auth/key",
headers={"Authorization": f"Bearer {api_key}"}
)
if response.status_code == 200:
print("\n✅ CONNEXION RÉUSSIE ! Voici le JSON de ton compte :")
# Affiche le JSON formaté de manière lisible
print(json.dumps(response.json(), indent=4))
else:
print(f"\n❌ ERREUR {response.status_code}: {response.text}")
except Exception as e:
print(f"Erreur de connexion : {e}")