iatoolkit 1.7.0__py3-none-any.whl → 1.15.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. iatoolkit/__init__.py +1 -1
  2. iatoolkit/common/routes.py +16 -3
  3. iatoolkit/common/util.py +8 -123
  4. iatoolkit/core.py +1 -0
  5. iatoolkit/infra/connectors/file_connector.py +10 -2
  6. iatoolkit/infra/connectors/google_drive_connector.py +3 -0
  7. iatoolkit/infra/connectors/local_file_connector.py +3 -0
  8. iatoolkit/infra/connectors/s3_connector.py +24 -1
  9. iatoolkit/infra/llm_providers/deepseek_adapter.py +17 -1
  10. iatoolkit/infra/llm_providers/gemini_adapter.py +117 -18
  11. iatoolkit/infra/llm_providers/openai_adapter.py +175 -18
  12. iatoolkit/infra/llm_response.py +13 -0
  13. iatoolkit/locales/en.yaml +82 -4
  14. iatoolkit/locales/es.yaml +79 -4
  15. iatoolkit/repositories/llm_query_repo.py +51 -18
  16. iatoolkit/repositories/models.py +16 -7
  17. iatoolkit/services/company_context_service.py +294 -133
  18. iatoolkit/services/configuration_service.py +140 -121
  19. iatoolkit/services/dispatcher_service.py +1 -4
  20. iatoolkit/services/knowledge_base_service.py +26 -4
  21. iatoolkit/services/llm_client_service.py +58 -2
  22. iatoolkit/services/prompt_service.py +251 -164
  23. iatoolkit/services/query_service.py +37 -18
  24. iatoolkit/services/storage_service.py +92 -0
  25. iatoolkit/static/js/chat_filepond.js +188 -63
  26. iatoolkit/static/js/chat_main.js +105 -52
  27. iatoolkit/static/styles/chat_iatoolkit.css +96 -0
  28. iatoolkit/system_prompts/query_main.prompt +24 -41
  29. iatoolkit/templates/chat.html +15 -6
  30. iatoolkit/views/base_login_view.py +1 -1
  31. iatoolkit/views/categories_api_view.py +111 -0
  32. iatoolkit/views/chat_view.py +1 -1
  33. iatoolkit/views/configuration_api_view.py +1 -1
  34. iatoolkit/views/login_view.py +1 -1
  35. iatoolkit/views/prompt_api_view.py +88 -7
  36. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/METADATA +1 -1
  37. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/RECORD +41 -39
  38. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/WHEEL +0 -0
  39. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE +0 -0
  40. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/licenses/LICENSE_COMMUNITY.md +0 -0
  41. {iatoolkit-1.7.0.dist-info → iatoolkit-1.15.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,92 @@
1
+ # Copyright (c) 2024 Fernando Libedinsky
2
+ # Product: IAToolkit
3
+ #
4
+ # IAToolkit is open source software.
5
+
6
+ import base64
7
+ import uuid
8
+ import logging
9
+ import mimetypes
10
+ import os
11
+ from injector import inject
12
+ from typing import Dict
13
+ from iatoolkit.infra.connectors.s3_connector import S3Connector
14
+ from iatoolkit.common.exceptions import IAToolkitException
15
+
16
+
17
+ class StorageService:
18
+ """
19
+ High level service for managing assets storage (images generated, attachments, etc).
20
+ Provides abstraction for file decoding and naming.
21
+ """
22
+
23
+ @inject
24
+ def __init__(self):
25
+ self.connector = self._init_connector()
26
+
27
+ def _init_connector(self) -> S3Connector:
28
+ # We configure S3 directly using environment variables
29
+ bucket = os.getenv("S3_BUCKET_NAME", "iatoolkit-assets")
30
+
31
+ auth = {
32
+ 'aws_access_key_id': os.getenv('AWS_ACCESS_KEY_ID'),
33
+ 'aws_secret_access_key': os.getenv('AWS_SECRET_ACCESS_KEY'),
34
+ 'region_name': os.getenv('AWS_REGION', 'us-east-1')
35
+ }
36
+
37
+ return S3Connector(
38
+ bucket=bucket,
39
+ prefix="", # Empty prefix to allow full control over keys
40
+ folder="",
41
+ auth=auth
42
+ )
43
+
44
+ def store_generated_image(self, company_short_name: str, base64_data: str, mime_type: str) -> Dict[str, str]:
45
+ """
46
+ Guarda una imagen generada por LLM (Base64) en el storage.
47
+
48
+ Returns:
49
+ Dict con:
50
+ - 'storage_key': La ruta interna (para guardar en BD)
51
+ - 'url': La URL firmada (para devolver al frontend inmediatamente)
52
+ """
53
+ try:
54
+ # 1. Decode Base64
55
+ # Sometimes the string comes with header 'data:image/png;base64,...', clean it
56
+ if "base64," in base64_data:
57
+ base64_data = base64_data.split("base64,")[1]
58
+
59
+ image_bytes = base64.b64decode(base64_data)
60
+
61
+ # 2. Generate unique name
62
+ ext = mimetypes.guess_extension(mime_type) or ".png"
63
+ filename = f"{uuid.uuid4()}{ext}"
64
+
65
+ # 3. Define folder structure: companies/{company}/generated/{filename}
66
+ storage_key = f"companies/{company_short_name}/generated_images/{filename}"
67
+
68
+ # 4. Upload
69
+ self.connector.upload_file(
70
+ file_path=storage_key,
71
+ content=image_bytes,
72
+ content_type=mime_type
73
+ )
74
+
75
+ logging.info(f"Generated image saved at: {storage_key}")
76
+
77
+ # 5. Generate temporary URL
78
+ url = self.connector.generate_presigned_url(storage_key)
79
+
80
+ return {
81
+ "storage_key": storage_key,
82
+ "url": url
83
+ }
84
+
85
+ except Exception as e:
86
+ error_msg = f"Error saving image to Storage: {str(e)}"
87
+ logging.error(error_msg)
88
+ raise IAToolkitException(IAToolkitException.ErrorType.FILE_IO_ERROR, error_msg)
89
+
90
+ def get_public_url(self, storage_key: str) -> str:
91
+ """Gets a fresh signed URL for an existing resource."""
92
+ return self.connector.generate_presigned_url(storage_key)
@@ -1,85 +1,210 @@
1
- $(document).ready(function () {
2
- const paperclipButton = $('#paperclip-button');
3
- const viewFilesButtonContainer = $('#view-files-button-container');
4
- const viewFilesButton = $('#view-files-button');
5
- const uploadedFilesModalElement = $('#uploadedFilesModal');
6
- const uploadedFilesModal = uploadedFilesModalElement; // En Bootstrap 3, el elemento jQuery es el modal
7
- const uploadedFilesList = $('#uploaded-files-list');
8
-
9
- // Initialize FilePond
10
- window.filePond = FilePond.create(
11
- document.querySelector('#file-upload'), {
1
+ document.addEventListener('DOMContentLoaded', function () {
2
+ // 1. Register FilePond Plugins
3
+ // Ensure plugin scripts are loaded in your base layout or template
4
+ if (typeof FilePondPluginFileEncode !== 'undefined') {
5
+ FilePond.registerPlugin(FilePondPluginFileEncode);
6
+ }
7
+ if (typeof FilePondPluginFileValidateSize !== 'undefined') {
8
+ FilePond.registerPlugin(FilePondPluginFileValidateSize);
9
+ }
10
+ if (typeof FilePondPluginFileValidateType !== 'undefined') {
11
+ FilePond.registerPlugin(FilePondPluginFileValidateType);
12
+ }
13
+ if (typeof FilePondPluginImagePreview !== 'undefined') {
14
+ FilePond.registerPlugin(FilePondPluginImagePreview);
15
+ }
16
+
17
+ // 2. Create FilePond instance on the hidden input
18
+ const inputElement = document.querySelector('input.filepond');
19
+
20
+ // FilePond base configuration
21
+ const filePond = FilePond.create(inputElement, {
12
22
  allowMultiple: true,
13
- labelIdle: '',
23
+ maxFiles: 5,
24
+ maxFileSize: '30MB',
25
+
26
+ // Extensive list of accepted types (Images, PDF, Text, Excel, Word)
27
+ acceptedFileTypes: [
28
+ 'image/*',
29
+ 'application/pdf',
30
+ 'text/plain',
31
+ 'text/csv',
32
+ 'application/vnd.ms-excel', // .xls
33
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
34
+ 'application/msword', // .doc
35
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx
36
+ ],
37
+ labelIdle: '', // Left empty because we use our own UI
14
38
  credits: false,
15
- allowFileSizeValidation: true,
16
- maxFileSize: '10MB',
17
- stylePanelLayout: null,
18
- itemInsertLocation: 'after',
19
- instantUpload: false,
39
+ storeAsFile: true, // Important for encode to work if used
20
40
  });
21
41
 
22
- $('.filepond--root').hide(); // Ocultar la UI de FilePond
42
+ // Expose globally so chat_main.js can access (getFiles, removeFiles)
43
+ window.filePond = filePond;
44
+
45
+
46
+ // 3. DOM references for the new custom UI
47
+ const dropzone = document.getElementById('chat-dropzone');
48
+ const fileListContainer = document.getElementById('inline-file-list');
49
+ const fileCounter = document.getElementById('file-counter');
50
+ const paperclipBtn = document.getElementById('paperclip-button');
51
+
52
+
53
+ // 4. Rendering Functions
54
+
55
+ /**
56
+ * Returns the Bootstrap icon class based on filename or file type.
57
+ */
58
+ function getFileIconClass(filename, type) {
59
+ const ext = filename.split('.').pop().toLowerCase();
60
+
61
+ // Images
62
+ if (type.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
63
+ return 'bi-file-earmark-image';
64
+ }
65
+ // PDF
66
+ if (type === 'application/pdf' || ext === 'pdf') {
67
+ return 'bi-file-earmark-pdf';
68
+ }
69
+ // Excel / Spreadsheets
70
+ if (['xls', 'xlsx', 'csv'].includes(ext) || type.includes('spreadsheet') || type.includes('excel')) {
71
+ return 'bi-file-earmark-excel';
72
+ }
73
+ // Word / Documents
74
+ if (['doc', 'docx'].includes(ext) || type.includes('word') || type.includes('document')) {
75
+ return 'bi-file-earmark-word';
76
+ }
77
+ // Text / Code
78
+ if (['txt', 'md', 'json', 'py', 'js', 'html', 'css'].includes(ext)) {
79
+ return 'bi-file-earmark-text';
80
+ }
81
+
82
+ // Default icon
83
+ return 'bi-file-earmark';
84
+ }
85
+
86
+ /**
87
+ * Rebuilds the visual file list below the input.
88
+ */
89
+ function renderFileList() {
90
+ if (!fileListContainer) return;
23
91
 
24
- // Función para actualizar la visibilidad del icono "ver archivos"
25
- function updateFileIconsVisibility() {
26
92
  const files = filePond.getFiles();
93
+ fileListContainer.innerHTML = ''; // Clear current list
94
+
27
95
  if (files.length > 0) {
28
- viewFilesButtonContainer.show();
29
- } else {
30
- viewFilesButtonContainer.hide();
31
- if (uploadedFilesModalElement.hasClass('in')) { // Si el modal está abierto y no hay archivos, ciérralo
32
- uploadedFilesModal.modal('hide');
96
+ fileListContainer.style.display = 'block';
97
+
98
+ // Update counter
99
+ if (fileCounter) {
100
+ fileCounter.textContent = `${files.length}/${filePond.maxFiles || 5}`;
101
+ fileCounter.style.display = 'inline-block';
33
102
  }
103
+
104
+ files.forEach(fileItem => {
105
+ const file = fileItem.file;
106
+ const iconClass = getFileIconClass(file.name, file.type || '');
107
+
108
+ // Create file row
109
+ const row = document.createElement('div');
110
+ row.className = 'file-list-item';
111
+
112
+ row.innerHTML = `
113
+ <i class="bi ${iconClass} file-icon"></i>
114
+ <span class="file-name" title="${file.name}">${file.name}</span>
115
+ <i class="bi bi-x-circle-fill file-remove" role="button" aria-label="Remove file"></i>
116
+ `;
117
+
118
+ // Click event on the remove button of the row
119
+ const removeBtn = row.querySelector('.file-remove');
120
+ removeBtn.addEventListener('click', (e) => {
121
+ e.stopPropagation(); // Prevent click propagation if nested in a clickable area
122
+ filePond.removeFile(fileItem.id);
123
+ });
124
+
125
+ fileListContainer.appendChild(row);
126
+ });
127
+ } else {
128
+ // Hide list and counter if no files
129
+ fileListContainer.style.display = 'none';
130
+ if (fileCounter) fileCounter.style.display = 'none';
34
131
  }
35
132
  }
36
133
 
37
- // Función para poblar el modal con los archivos y botones de eliminar
38
- function populateFilesModal() {
39
- uploadedFilesList.empty(); // Limpiar lista anterior
40
- const files = filePond.getFiles();
41
134
 
42
- if (files.length === 0) {
43
- uploadedFilesList.append('<li class="list-group-item">No hay archivos adjuntos.</li>');
44
- return;
45
- }
135
+ // 5. Interaction Event Management
46
136
 
47
- files.forEach(file => {
48
- const listItem = $(`
49
- <li class="list-group-item d-flex justify-content-between align-items-center">
50
- <span class="file-name-modal">${file.filename}</span>
51
- <button type="button" class="btn btn-sm btn-outline-danger remove-file-btn" data-file-id="${file.id}" title="Eliminar archivo">
52
- <i class="bi bi-trash-fill"></i>
53
- </button>
54
- </li>
55
- `);
56
- uploadedFilesList.append(listItem);
137
+ // -- Custom Dropzone Robust Handling --
138
+ if (dropzone) {
139
+ // Prevent default browser behavior for drag events
140
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
141
+ dropzone.addEventListener(eventName, (e) => {
142
+ e.preventDefault();
143
+ e.stopPropagation();
144
+ }, false);
145
+ });
146
+
147
+ // Highlight dropzone on drag enter/over
148
+ ['dragenter', 'dragover'].forEach(eventName => {
149
+ dropzone.addEventListener(eventName, () => {
150
+ dropzone.classList.add('drag-over');
151
+ }, false);
152
+ });
153
+
154
+ // Remove highlight on drag leave or drop
155
+ // Note: checking relatedTarget prevents flickering when dragging over child elements
156
+ dropzone.addEventListener('dragleave', (e) => {
157
+ if (!dropzone.contains(e.relatedTarget)) {
158
+ dropzone.classList.remove('drag-over');
159
+ }
160
+ }, false);
161
+
162
+ dropzone.addEventListener('drop', (e) => {
163
+ dropzone.classList.remove('drag-over');
164
+
165
+ // Pass dropped files to FilePond
166
+ // We convert FileList to Array to ensure compatibility
167
+ if (e.dataTransfer && e.dataTransfer.files.length > 0) {
168
+ filePond.addFiles(Array.from(e.dataTransfer.files));
169
+ }
170
+ }, false);
171
+
172
+ // Click on zone opens native selector
173
+ dropzone.addEventListener('click', function() {
174
+ filePond.browse();
175
+ });
176
+ }
177
+
178
+ // -- "Clip" Button (Legacy design compatibility) --
179
+ if (paperclipBtn) {
180
+ paperclipBtn.addEventListener('click', function() {
181
+ filePond.browse();
57
182
  });
58
183
  }
59
184
 
60
- // Event listeners de FilePond
61
- window.filePond.on('addfile', () => updateFileIconsVisibility());
62
- window.filePond.on('removefile', () => {
63
- updateFileIconsVisibility();
64
- if (uploadedFilesModalElement.hasClass('in')) {
65
- populateFilesModal();
185
+
186
+ // 6. FilePond Hooks for Reactivity
187
+
188
+ // On file add (even if validation error, FilePond manages array)
189
+ filePond.on('addfile', (error, file) => {
190
+ if (error) {
191
+ console.error('FilePond Error:', error);
192
+ // Optional: Show error toast if file is invalid (e.g., too large)
193
+ return;
66
194
  }
195
+ renderFileList();
67
196
  });
68
197
 
69
- // Event listeners de los botones de la UI
70
- paperclipButton.on('click', () => window.filePond.browse());
71
- viewFilesButton.on('click', () => {
72
- populateFilesModal();
73
- uploadedFilesModal.modal('show');
74
- });
75
- uploadedFilesList.on('click', '.remove-file-btn', function () {
76
- const fileIdToRemove = $(this).data('file-id');
77
- if (fileIdToRemove) {
78
- window.filePond.removeFile(fileIdToRemove);
79
- }
198
+ // On file remove
199
+ filePond.on('removefile', (error, file) => {
200
+ renderFileList();
80
201
  });
81
202
 
82
- // Inicializar visibilidad al cargar
83
- updateFileIconsVisibility();
84
- });
203
+ // Event for general error (e.g., type not allowed on drop)
204
+ filePond.on('warning', (error) => {
205
+ console.warn('FilePond Warning:', error);
206
+ });
85
207
 
208
+ // Initialization: Initial render in case browser cached state
209
+ renderFileList();
210
+ });
@@ -58,7 +58,6 @@ $(document).ready(function () {
58
58
 
59
59
  });
60
60
 
61
-
62
61
  /**
63
62
  * Main function to handle sending a chat message.
64
63
  */
@@ -93,16 +92,16 @@ const handleChatMessage = async function () {
93
92
  if (paramsString) { displayMessage += `: ${paramsString}`; }
94
93
  }
95
94
 
96
- // Simplificado: Si no hay mensaje, el 'finally' se encargará de limpiar.
97
95
  if (!displayMessage) {
98
96
  return;
99
97
  }
100
98
 
101
- displayUserMessage(displayMessage, isEditable, question);
99
+ const files = window.filePond.getFiles();
100
+
101
+ displayUserMessage(displayMessage, isEditable, question, files);
102
102
  showSpinner();
103
103
  resetAllInputs();
104
104
 
105
- const files = window.filePond.getFiles();
106
105
  const filesBase64 = await Promise.all(files.map(fileItem => toBase64(fileItem.file)));
107
106
 
108
107
  const data = {
@@ -112,59 +111,18 @@ const handleChatMessage = async function () {
112
111
  files: filesBase64.map(f => ({ filename: f.name, content: f.base64 })),
113
112
  user_identifier: window.user_identifier,
114
113
  model: (window.currentLlmModel || window.defaultLlmModel || '')
115
-
116
114
  };
117
115
 
118
116
  const responseData = await callToolkit("/api/llm_query", data, "POST");
119
- if (responseData && responseData.answer) {
120
- // CAMBIO: contenedor principal para la respuesta del bot
121
- const botMessageContainer = $('<div>').addClass('bot-message-container');
122
-
123
- // 1. Si hay reasoning_content, agregar el acordeón colapsable
124
- if (responseData.reasoning_content) {
125
- const uniqueId = 'reasoning-' + Date.now(); // ID único para el collapse
126
-
127
- const reasoningBlock = $(`
128
- <div class="reasoning-block">
129
- <button class="reasoning-toggle btn btn-sm btn-link text-decoration-none p-0"
130
- type="button"
131
- data-bs-toggle="collapse"
132
- data-bs-target="#${uniqueId}"
133
- aria-expanded="false"
134
- aria-controls="${uniqueId}">
135
- <i class="bi bi-lightbulb me-1"></i> ${t_js('show_reasoning')}
136
- </button>
137
-
138
- <div class="collapse mt-2" id="${uniqueId}">
139
- <div class="reasoning-card">
140
- ${responseData.reasoning_content}
141
- </div>
142
- </div>
143
- </div>
144
- `);
145
- botMessageContainer.append(reasoningBlock);
146
- }
147
-
148
- // 2. Agregar la respuesta final
149
- const answerSection = $('<div>').addClass('answer-section llm-output').append(responseData.answer);
150
- botMessageContainer.append(answerSection);
151
117
 
152
- // 3. Mostrar el contenedor completo
153
- displayBotMessage(botMessageContainer);
118
+ // Delegamos el procesamiento de la respuesta a la nueva función
119
+ processBotResponse(responseData);
154
120
 
155
- }
156
121
  } catch (error) {
157
122
  if (error.name === 'AbortError') {
158
-
159
- // Usando jQuery estándar para construir el elemento ---
160
- const icon = $('<i>').addClass('bi bi-stop-circle me-2'); // Icono sin "fill" para un look más ligero
123
+ const icon = $('<i>').addClass('bi bi-stop-circle me-2');
161
124
  const textSpan = $('<span>').text('La generación de la respuesta ha sido detenida.');
162
-
163
- const abortMessage = $('<div>')
164
- .addClass('system-message')
165
- .append(icon)
166
- .append(textSpan);
167
-
125
+ const abortMessage = $('<div>').addClass('system-message').append(icon).append(textSpan);
168
126
  displayBotMessage(abortMessage);
169
127
  } else {
170
128
  console.error("Error in handleChatMessage:", error);
@@ -172,17 +130,78 @@ const handleChatMessage = async function () {
172
130
  displayBotMessage(errorSection);
173
131
  }
174
132
  } finally {
175
- // Este bloque se ejecuta siempre, garantizando que el estado se limpie.
176
133
  isRequestInProgress = false;
177
134
  hideSpinner();
178
135
  toggleSendStopButtons(false);
179
136
  updateSendButtonState();
180
137
  if (window.filePond) {
181
- window.filePond.removeFiles();
138
+ window.filePond.removeFiles();
182
139
  }
183
140
  }
184
141
  };
185
142
 
143
+ /**
144
+ * Processes the response data from the LLM and displays it in the chat.
145
+ * Handles multimodal content: uses 'answer' for text (HTML) and 'content_parts' for images.
146
+ * @param {object} responseData - The JSON response from the server.
147
+ */
148
+ function processBotResponse(responseData) {
149
+ if (!responseData || (!responseData.answer && !responseData.content_parts)) {
150
+ return;
151
+ }
152
+
153
+ const botMessageContainer = $('<div>').addClass('bot-message-container');
154
+
155
+ // 1. Si hay reasoning_content, agregar el acordeón colapsable
156
+ if (responseData.reasoning_content) {
157
+ const uniqueId = 'reasoning-' + Date.now();
158
+ const reasoningBlock = $(`
159
+ <div class="reasoning-block">
160
+ <button class="reasoning-toggle btn btn-sm btn-link text-decoration-none p-0"
161
+ type="button" data-bs-toggle="collapse" data-bs-target="#${uniqueId}"
162
+ aria-expanded="false" aria-controls="${uniqueId}">
163
+ <i class="bi bi-lightbulb me-1"></i> ${t_js('show_reasoning')}
164
+ </button>
165
+ <div class="collapse mt-2" id="${uniqueId}">
166
+ <div class="reasoning-card">${responseData.reasoning_content}</div>
167
+ </div>
168
+ </div>
169
+ `);
170
+ botMessageContainer.append(reasoningBlock);
171
+ }
172
+
173
+ // 2. Agregar la respuesta final
174
+ const answerSection = $('<div>').addClass('answer-section llm-output');
175
+
176
+ // A. Texto: Usamos 'answer' porque contiene el HTML procesado y limpio del backend.
177
+ // Evitamos usar content_parts[type=text] porque contiene el JSON crudo del LLM.
178
+ if (responseData.answer) {
179
+ answerSection.append(responseData.answer);
180
+ }
181
+
182
+ // B. Imágenes: Iteramos content_parts buscando SOLO imágenes para adjuntarlas.
183
+ if (responseData.content_parts && responseData.content_parts.length > 0) {
184
+ responseData.content_parts.forEach(part => {
185
+ if (part.type === 'image' && part.source && part.source.url) {
186
+ const imgContainer = $('<div>').addClass('image-part my-3 text-center');
187
+ const img = $('<img>')
188
+ .attr('src', part.source.url)
189
+ .addClass('img-fluid rounded shadow-sm border')
190
+ .css({'max-height': '400px', 'cursor': 'pointer'})
191
+ .on('click', () => window.open(part.source.url, '_blank'));
192
+
193
+ imgContainer.append(img);
194
+ answerSection.append(imgContainer);
195
+ }
196
+ });
197
+ }
198
+
199
+ botMessageContainer.append(answerSection);
200
+
201
+ // 3. Mostrar el contenedor completo
202
+ displayBotMessage(botMessageContainer);
203
+ }
204
+
186
205
 
187
206
  /**
188
207
  * Resets all inputs to their initial state.
@@ -316,14 +335,48 @@ const callToolkit = async function(apiPath, data, method, timeoutMs = 500000) {
316
335
  * @param {string} message - The full message string to display.
317
336
  * @param {boolean} isEditable - Determines if the edit icon should be shown.
318
337
  * @param {string} originalQuestion - The original text to put back in the textarea for editing.
338
+ * @param {Array} [files] - Optional array of FilePond file items.
319
339
  */
320
- const displayUserMessage = function(message, isEditable, originalQuestion) {
340
+ const displayUserMessage = function(message, isEditable, originalQuestion, files = []) {
321
341
  const chatContainer = $('#chat-container');
322
342
  const userMessage = $('<div>').addClass('message shadow-sm');
323
343
  const messageText = $('<span>').text(message);
324
344
 
325
345
  userMessage.append(messageText);
326
346
 
347
+ // Renderizar previsualizaciones de archivos si existen
348
+ if (files && files.length > 0) {
349
+ const attachmentsContainer = $('<div>').addClass('mt-2 d-flex flex-wrap gap-2 ms-3');
350
+
351
+ files.forEach(fileItem => {
352
+ const file = fileItem.file;
353
+
354
+ if (file.type && file.type.startsWith('image/')) {
355
+ // Previsualización de imagen usando URL temporal
356
+ const imgUrl = URL.createObjectURL(file);
357
+ const img = $('<img>')
358
+ .attr('src', imgUrl)
359
+ .addClass('rounded border')
360
+ .css({
361
+ 'max-height': '80px',
362
+ 'max-width': '120px',
363
+ 'object-fit': 'cover',
364
+ 'cursor': 'pointer'
365
+ })
366
+ .on('click', () => window.open(imgUrl, '_blank')); // Click para ver en grande
367
+ attachmentsContainer.append(img);
368
+ } else {
369
+ // Icono genérico para documentos
370
+ const badge = $('<span>')
371
+ .addClass('badge bg-light text-dark border p-2')
372
+ .html(`<i class="bi bi-file-earmark-text me-1"></i> ${file.name}`);
373
+ attachmentsContainer.append(badge);
374
+ }
375
+ });
376
+
377
+ userMessage.append(attachmentsContainer);
378
+ }
379
+
327
380
  if (isEditable) {
328
381
  const editIcon = $('<i>').addClass('p-2 bi bi-pencil-fill edit-icon edit-pencil').attr('title', 'Edit query').on('click', function () {
329
382
  $('#question').val(originalQuestion)