feat:weiter chatbot implementiert

This commit is contained in:
Ida Dittrich 2026-01-05 15:12:47 +01:00
parent eb280dbae1
commit b0826a3f9a
9 changed files with 664 additions and 68 deletions

View file

@ -73,6 +73,8 @@ export async function startChatbotStreamApi(
): Promise<void> { ): Promise<void> {
try { try {
// Prepare request body // Prepare request body
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
const body: any = { const body: any = {
prompt: requestBody.prompt, prompt: requestBody.prompt,
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }), ...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
@ -80,6 +82,8 @@ export async function startChatbotStreamApi(
...(requestBody.metadata && { metadata: requestBody.metadata }) ...(requestBody.metadata && { metadata: requestBody.metadata })
}; };
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
// Add workflowId to query params if provided // Add workflowId to query params if provided
const url = requestBody.workflowId const url = requestBody.workflowId
? `/api/chatbot/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}` ? `/api/chatbot/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`

View file

@ -20,7 +20,6 @@
.actionButton:hover { .actionButton:hover {
background: var(--color-secondary-hover); background: var(--color-secondary-hover);
transform: translateY(-1px);
} }
.actionButton:disabled { .actionButton:disabled {

View file

@ -64,7 +64,7 @@ export function FormGeneratorControls({
filterFocused, filterFocused,
onFilterFocus, onFilterFocus,
selectedCount, selectedCount,
displayData: _displayData, displayData,
onDeleteSingle, onDeleteSingle,
onDeleteMultiple, onDeleteMultiple,
onRefresh, onRefresh,
@ -76,6 +76,9 @@ export function FormGeneratorControls({
}: FormGeneratorControlsProps) { }: FormGeneratorControlsProps) {
const { t } = useLanguage(); const { t } = useLanguage();
// Check if all items are selected
const allItemsSelected = selectedCount > 0 && displayData.length > 0 && selectedCount === displayData.length;
// Filter fields that are filterable // Filter fields that are filterable
const filterableFields = fields.filter(field => { const filterableFields = fields.filter(field => {
if (field.type === 'readonly') return false; if (field.type === 'readonly') return false;
@ -159,7 +162,8 @@ export function FormGeneratorControls({
{/* Delete Controls - Show when items are selected */} {/* Delete Controls - Show when items are selected */}
{selectable && selectedCount > 0 && ( {selectable && selectedCount > 0 && (
<div className={styles.deleteControlsIntegrated}> <div className={styles.deleteControlsIntegrated}>
{selectedCount === 1 && onDeleteSingle && ( {/* Show delete single only if exactly 1 item selected AND not all items */}
{selectedCount === 1 && !allItemsSelected && onDeleteSingle && (
<Button <Button
onClick={onDeleteSingle} onClick={onDeleteSingle}
variant="primary" variant="primary"
@ -169,14 +173,15 @@ export function FormGeneratorControls({
{t('formgen.delete.single', 'Delete')} {t('formgen.delete.single', 'Delete')}
</Button> </Button>
)} )}
{selectedCount > 1 && onDeleteMultiple && ( {/* Show delete multiple if more than 1 selected OR all items are selected */}
{(selectedCount > 1 || allItemsSelected) && onDeleteMultiple && (
<Button <Button
onClick={onDeleteMultiple} onClick={onDeleteMultiple}
variant="primary" variant="primary"
size="sm" size="sm"
icon={FaTrash} icon={FaTrash}
> >
{selectedCount === displayData.length && displayData.length > 0 {allItemsSelected
? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString()) ? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString())
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())} : t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
</Button> </Button>

View file

@ -20,6 +20,7 @@
max-height: none; max-height: none;
flex: 1; flex: 1;
padding-right: 0.5rem; padding-right: 0.5rem;
padding-left: 0.5rem;
} }
.emptyList { .emptyList {
@ -107,6 +108,65 @@
.headerButtonWrapper { .headerButtonWrapper {
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Style buttons inside headerButtonWrapper to match ActionButton styling */
.headerButtonWrapper button {
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
border: none;
border-radius: 50%;
font-size: 12px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
position: relative;
min-width: 28px;
min-height: 28px;
background: var(--color-secondary);
color: var(--color-bg);
}
.headerButtonWrapper button:hover {
background: var(--color-secondary-hover);
}
.headerButtonWrapper button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.headerButtonWrapper button:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
}
/* Style icons inside headerButtonWrapper buttons */
.headerButtonWrapper button svg,
.headerButtonWrapper button .actionIcon {
font-size: 16px;
height: 16px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.headerDeleteButtonContainer {
flex-shrink: 0;
margin-left: 0.5rem;
position: relative;
}
.headerDeleteButton {
flex-shrink: 0;
} }
.listCount { .listCount {
@ -165,7 +225,7 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0.875rem 0.75rem; padding: 0.875rem 1rem;
background: transparent; background: transparent;
border: none; border: none;
border-top: 1px solid var(--color-medium-gray); border-top: 1px solid var(--color-medium-gray);
@ -248,6 +308,17 @@
gap: 0.375rem; gap: 0.375rem;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding-left: 0;
margin-left: 0;
}
.metadataFields {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.625rem;
margin-top: 0.375rem;
flex-wrap: wrap;
} }
.itemField { .itemField {
@ -262,27 +333,69 @@
margin-bottom: 0; margin-bottom: 0;
} }
.itemField:nth-child(2), .itemField:first-child .fieldValue {
.itemField:nth-child(3) { font-size: 1rem;
font-weight: 600;
color: var(--color-text);
}
.metadataFields .itemField {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-top: 0.375rem; margin: 0;
flex-shrink: 0;
} }
.itemField:nth-child(2) { .metadataFields .itemField .fieldValue {
margin-right: 0.625rem; font-size: 0.75rem;
} font-weight: 400;
color: var(--color-text-secondary, #666);
.itemField:nth-child(3) { opacity: 0.8;
margin-left: 0;
}
.itemField:nth-child(2) .fieldValue,
.itemField:nth-child(3) .fieldValue {
display: inline; display: inline;
} }
/* Date field styling (first in metadata) */
.metadataFields .itemField:first-child .fieldValue {
font-size: 0.75rem;
font-weight: 400;
color: var(--color-text-secondary, #666);
opacity: 0.8;
}
/* Status Badge Styling */
.statusBadge {
display: inline-block;
padding: 0.25rem 0.625rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
text-transform: capitalize;
}
/* Status color variants */
.statusBadge.completed {
background-color: rgba(34, 197, 94, 0.15);
color: #16a34a;
}
.statusBadge.pending {
background-color: rgba(251, 191, 36, 0.15);
color: #d97706;
}
.statusBadge.failed {
background-color: rgba(239, 68, 68, 0.15);
color: #dc2626;
}
.statusBadge.active {
background-color: rgba(59, 130, 246, 0.15);
color: #2563eb;
}
.fieldLabel { .fieldLabel {
display: none; display: none;
} }
@ -496,11 +609,9 @@
@keyframes slideInFromTop { @keyframes slideInFromTop {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0);
} }
} }

View file

@ -1,6 +1,7 @@
import React, { useState, useMemo, useRef, useEffect } from 'react'; import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './FormGeneratorList.module.css'; import styles from './FormGeneratorList.module.css';
import actionButtonStyles from '../ActionButtons/ActionButton.module.css';
import { import {
EditActionButton, EditActionButton,
DeleteActionButton, DeleteActionButton,
@ -13,6 +14,7 @@ import {
import { formatUnixTimestamp } from '../../../utils/time'; import { formatUnixTimestamp } from '../../../utils/time';
import TextField from '../../UiComponents/TextField/TextField'; import TextField from '../../UiComponents/TextField/TextField';
import { FormGeneratorControls } from '../FormGeneratorControls'; import { FormGeneratorControls } from '../FormGeneratorControls';
import { IoIosTrash, IoIosCheckmark, IoIosClose } from "react-icons/io";
import { import {
isSelectType, isSelectType,
isCheckboxType, isCheckboxType,
@ -185,6 +187,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set()); const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize); const [currentPageSize, setCurrentPageSize] = useState(pageSize);
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Check if backend pagination is supported // Check if backend pagination is supported
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function'; const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
@ -244,6 +248,12 @@ export function FormGeneratorList<T extends Record<string, any>>({
// Data is already filtered, sorted, and paginated by the backend // Data is already filtered, sorted, and paginated by the backend
const displayData = data; const displayData = data;
// Check if all items are selected
const allItemsSelected = selectedItems.size > 0 && displayData.length > 0 && selectedItems.size === displayData.length;
// Check if any items are selected
const hasSelectedItems = selectedItems.size > 0;
// Get pagination info from backend // Get pagination info from backend
const totalPages = useMemo(() => { const totalPages = useMemo(() => {
if (!supportsBackendPagination || !hookData?.pagination) { if (!supportsBackendPagination || !hookData?.pagination) {
@ -324,31 +334,138 @@ export function FormGeneratorList<T extends Record<string, any>>({
} }
}; };
// Handle delete single item // Handle delete multiple items click (show confirmation)
const handleDeleteSingle = (row: T, index: number) => { const handleDeleteMultipleClick = () => {
if (onDelete) { if (selectedItems.size === 0 || isDeleting) return;
onDelete(row); setIsConfirmingDelete(true);
if (selectedItems.has(index)) { };
const newSelected = new Set(selectedItems);
newSelected.delete(index); // Handle confirm delete
setSelectedItems(newSelected); const handleConfirmDelete = async () => {
if (onItemSelect) { if (selectedItems.size === 0) {
const selectedData = Array.from(newSelected).map(i => displayData[i]); setIsConfirmingDelete(false);
onItemSelect(selectedData); return;
} }
setIsDeleting(true);
setIsConfirmingDelete(false);
try {
const selectedData = Array.from(selectedItems).map(i => displayData[i]);
console.log('Deleting items:', selectedData.length, 'items', selectedData);
// Try to use hookData first (like DeleteActionButton does)
if (hookData) {
const handleDelete = hookData.handleDelete || hookData.handleDeleteMultiple;
const removeOptimistically = hookData.removeOptimistically || hookData.removeFileOptimistically;
const refetch = hookData.refetch;
const idField = 'id'; // Default ID field, could be made configurable
if (handleDelete) {
console.log('Using hookData.handleDelete');
// Get IDs from selected items
const selectedIds = selectedData.map(row => (row as any)[idField]).filter(Boolean);
console.log('Selected IDs:', selectedIds);
// Optimistically remove items from UI
if (removeOptimistically) {
selectedIds.forEach(id => removeOptimistically(id));
}
// Delete each item
const deletePromises = selectedIds.map(id => handleDelete(id));
const results = await Promise.all(deletePromises);
// Check if all deletions succeeded
const allSucceeded = results.every(result => result !== false);
if (allSucceeded) {
// If we used optimistic removal, don't refetch immediately
if (!removeOptimistically && refetch) {
await refetch();
}
} else {
// Some deletions failed, refetch to restore state
if (refetch) {
await refetch();
}
throw new Error('Some items failed to delete');
}
} else if (onDeleteMultiple) {
console.log('Using onDeleteMultiple prop');
const result = onDeleteMultiple(selectedData) as any;
if (result && typeof result.then === 'function') {
await result;
}
} else if (onDelete) {
console.log('Using onDelete prop for each item');
for (const row of selectedData) {
const result = onDelete(row) as any;
if (result && typeof result.then === 'function') {
await result;
}
}
} else {
console.warn('No delete handler found in hookData or props');
alert('No delete handler configured');
return;
}
} else if (onDeleteMultiple) {
console.log('Using onDeleteMultiple prop (no hookData)');
const result = onDeleteMultiple(selectedData) as any;
if (result && typeof result.then === 'function') {
await result;
}
} else if (onDelete) {
console.log('Using onDelete prop for each item (no hookData)');
for (const row of selectedData) {
const result = onDelete(row) as any;
if (result && typeof result.then === 'function') {
await result;
}
}
} else {
console.warn('No delete handler provided');
alert('No delete handler configured');
return;
}
// Clear selection after deletion
setSelectedItems(new Set());
onItemSelect?.([]);
console.log('Delete completed, selection cleared');
} catch (error) {
console.error('Delete failed:', error);
alert(`Delete failed: ${error}`);
} finally {
setIsDeleting(false);
}
};
// Handle cancel delete
const handleCancelDelete = () => {
setIsConfirmingDelete(false);
};
// Handle clicks outside delete confirmation buttons
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (isConfirmingDelete) {
const target = event.target as HTMLElement;
if (!target.closest(`.${styles.headerDeleteButtonContainer}`)) {
setIsConfirmingDelete(false);
} }
} }
}; };
// Handle delete multiple items if (isConfirmingDelete) {
const handleDeleteMultiple = () => { document.addEventListener('mousedown', handleClickOutside);
if (onDeleteMultiple && selectedItems.size > 0) { return () => {
const selectedData = Array.from(selectedItems).map(i => displayData[i]); document.removeEventListener('mousedown', handleClickOutside);
onDeleteMultiple(selectedData);
setSelectedItems(new Set());
onItemSelect?.([]);
}
}; };
}
}, [isConfirmingDelete]);
// Handle page size change // Handle page size change
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
@ -356,6 +473,25 @@ export function FormGeneratorList<T extends Record<string, any>>({
setCurrentPage(1); setCurrentPage(1);
}; };
// Get status badge class based on status value
const getStatusBadgeClass = (value: any): string => {
const statusValue = String(value || '').toLowerCase().trim();
if (statusValue === 'completed' || statusValue === 'success' || statusValue === 'done') {
return styles.completed;
}
if (statusValue === 'pending' || statusValue === 'waiting' || statusValue === 'in_progress' || statusValue === 'in progress') {
return styles.pending;
}
if (statusValue === 'failed' || statusValue === 'error' || statusValue === 'cancelled' || statusValue === 'canceled') {
return styles.failed;
}
if (statusValue === 'active' || statusValue === 'running') {
return styles.active;
}
return '';
};
// Format field value // Format field value
const formatFieldValue = (value: any, field: FieldConfig, row: T) => { const formatFieldValue = (value: any, field: FieldConfig, row: T) => {
if (field.formatter) { if (field.formatter) {
@ -459,10 +595,27 @@ export function FormGeneratorList<T extends Record<string, any>>({
// Render field input // Render field input
const renderFieldInput = (field: FieldConfig, value: any, row: T, _index: number) => { const renderFieldInput = (field: FieldConfig, value: any, row: T, _index: number) => {
const isStatusField = field.key.toLowerCase().includes('status');
if (field.type === 'readonly' || !field.editable) { if (field.type === 'readonly' || !field.editable) {
const formattedValue = formatFieldValue(value, field, row);
// Apply status badge styling for status fields
if (isStatusField) {
const statusClass = getStatusBadgeClass(value);
return ( return (
<div className={styles.fieldValue} key={field.key}> <div className={styles.fieldValue} key={field.key}>
{formatFieldValue(value, field, row)} <span className={`${styles.statusBadge} ${statusClass}`}>
{formattedValue}
</span>
</div>
);
}
return (
<div className={styles.fieldValue} key={field.key}>
{formattedValue}
</div> </div>
); );
} }
@ -515,7 +668,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
return ( return (
<div className={`${styles.formGeneratorList} ${className}`}> <div className={`${styles.formGeneratorList} ${className}`}>
{(searchable || filterable || (selectable && selectedItems.size > 0)) && ( {(searchable || filterable) && selectedItems.size === 0 && (
<FormGeneratorControls <FormGeneratorControls
fields={detectedFields} fields={detectedFields}
searchTerm={searchTerm} searchTerm={searchTerm}
@ -528,12 +681,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
onFilterFocus={handleFilterFocus} onFilterFocus={handleFilterFocus}
selectedCount={selectedItems.size} selectedCount={selectedItems.size}
displayData={displayData} displayData={displayData}
onDeleteSingle={selectedItems.size === 1 && onDelete ? () => { onDeleteSingle={undefined}
const selectedIndex = Array.from(selectedItems)[0]; onDeleteMultiple={undefined}
const selectedRow = displayData[selectedIndex];
handleDeleteSingle(selectedRow, selectedIndex);
} : undefined}
onDeleteMultiple={(selectedItems.size > 1 || (selectedItems.size === displayData.length && displayData.length > 0)) && onDeleteMultiple ? handleDeleteMultiple : undefined}
onRefresh={onRefresh} onRefresh={onRefresh}
searchable={searchable} searchable={searchable}
filterable={filterable} filterable={filterable}
@ -573,6 +722,47 @@ export function FormGeneratorList<T extends Record<string, any>>({
{title && data.length > 0 && ( {title && data.length > 0 && (
<span className={styles.listCount}>({data.length})</span> <span className={styles.listCount}>({data.length})</span>
)} )}
{hasSelectedItems && (onDeleteMultiple || onDelete) && (
<div className={styles.headerDeleteButtonContainer}>
{isConfirmingDelete ? (
<div className={actionButtonStyles.deleteConfirmButtons}>
<button
onClick={handleConfirmDelete}
className={`${actionButtonStyles.actionButton} ${actionButtonStyles.confirmButton}`}
title={t('formgen.delete.confirm', 'Confirm delete')}
disabled={isDeleting}
>
<span className={actionButtonStyles.actionIcon}>
<IoIosCheckmark />
</span>
</button>
<button
onClick={handleCancelDelete}
className={`${actionButtonStyles.actionButton} ${actionButtonStyles.cancelButton}`}
title={t('formgen.delete.cancel', 'Cancel delete')}
disabled={isDeleting}
>
<span className={actionButtonStyles.actionIcon}>
<IoIosClose />
</span>
</button>
</div>
) : (
<button
onClick={handleDeleteMultipleClick}
className={`${actionButtonStyles.actionButton} ${actionButtonStyles.delete} ${styles.headerDeleteButton} ${isDeleting ? actionButtonStyles.loading : ''}`}
title={allItemsSelected
? t('formgen.delete.all', `Delete all ${selectedItems.size} items`)
: t('formgen.delete.multiple', `Delete ${selectedItems.size} selected items`)}
disabled={isDeleting}
>
<span className={actionButtonStyles.actionIcon}>
<IoIosTrash />
</span>
</button>
)}
</div>
)}
{headerButton && ( {headerButton && (
<div className={styles.headerButtonWrapper}> <div className={styles.headerButtonWrapper}>
{headerButton} {headerButton}
@ -713,17 +903,61 @@ export function FormGeneratorList<T extends Record<string, any>>({
{/* Fields */} {/* Fields */}
<div className={styles.itemFields}> <div className={styles.itemFields}>
{detectedFields.map(field => { {detectedFields.map((field, fieldIndex) => {
const cellValue = row[field.key]; const cellValue = row[field.key];
const customClassName = field.cellClassName ? field.cellClassName(cellValue, row) : ''; const customClassName = field.cellClassName ? field.cellClassName(cellValue, row) : '';
// First field (name) - render normally
if (fieldIndex === 0) {
return ( return (
<div <div
key={field.key} key={field.key}
className={`${styles.itemField} ${customClassName}`} className={`${styles.itemField} ${customClassName}`}
> >
<label className={styles.fieldLabel}>{field.label}</label> <label className={styles.fieldLabel}>{field.label}</label>
{renderFieldInput(field, cellValue, row)} {renderFieldInput(field, cellValue, row, fieldIndex)}
</div>
);
}
// Second and third fields (date and status) - wrap in container
if (fieldIndex === 1) {
const nextField = detectedFields[2];
const nextFieldValue = nextField ? row[nextField.key] : null;
return (
<div key={`metadata-${field.key}`} className={styles.metadataFields}>
<div
className={`${styles.itemField} ${customClassName}`}
>
<label className={styles.fieldLabel}>{field.label}</label>
{renderFieldInput(field, cellValue, row, fieldIndex)}
</div>
{nextField && (
<div
className={`${styles.itemField} ${nextField.cellClassName ? nextField.cellClassName(nextFieldValue, row) : ''}`}
>
<label className={styles.fieldLabel}>{nextField.label}</label>
{renderFieldInput(nextField, nextFieldValue, row, 2)}
</div>
)}
</div>
);
}
// Skip third field if it was already rendered with second field
if (fieldIndex === 2 && detectedFields.length > 2) {
return null;
}
// Any additional fields beyond the first three
return (
<div
key={field.key}
className={`${styles.itemField} ${customClassName}`}
>
<label className={styles.fieldLabel}>{field.label}</label>
{renderFieldInput(field, cellValue, row, fieldIndex)}
</div> </div>
); );
})} })}

View file

@ -1016,9 +1016,12 @@ const PageRenderer: React.FC<PageRendererProps> = ({
} }
}; };
// Check if we have file management // Check if we have file management (dashboard style with workflowFiles)
const hasFileManagement = !!(hookData.handleFileUpload && hookData.workflowFiles !== undefined); const hasFileManagement = !!(hookData.handleFileUpload && hookData.workflowFiles !== undefined);
// Check if we have chatbot file upload (simpler style with uploadedFiles)
const hasChatbotFileUpload = !!(hookData.handleFileUpload && hookData.uploadedFiles !== undefined);
// Check RBAC permissions for prompt selector and workflow mode selector // Check RBAC permissions for prompt selector and workflow mode selector
// Show prompt selector if user has permission to view/read prompts (even if no prompts exist yet) // Show prompt selector if user has permission to view/read prompts (even if no prompts exist yet)
const showPromptSelector = hookData.promptPermission && const showPromptSelector = hookData.promptPermission &&
@ -1291,6 +1294,130 @@ const PageRenderer: React.FC<PageRendererProps> = ({
); );
} }
// Chatbot file upload layout (simpler than dashboard)
if (hasChatbotFileUpload) {
const uploadedFiles = hookData.uploadedFiles || [];
return (
<div key={content.id} style={{
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
margin: '1.5rem 0',
width: '100%',
flexShrink: 0
}}>
{/* Input and buttons row */}
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
width: '100%'
}}>
<div style={{ flex: 1 }}>
<TextField
value={hookData.inputValue || ''}
onChange={hookData.onInputChange}
placeholder={resolveLanguageText(config.placeholder, t)}
size={config.textFieldSize || 'md'}
disabled={hookData.isSubmitting || false}
{...({ onKeyDown: handleKeyDown } as any)}
/>
</div>
<div style={{ flexShrink: 0, display: 'flex', gap: '8px' }}>
<UploadButton
onUpload={hookData.handleFileUpload ? async (file: File) => {
const result = await hookData.handleFileUpload!(file);
// Error handling is done in the hook
} : async () => {}}
disabled={hookData.isSubmitting || false}
loading={hookData.uploadingFile || false}
variant="secondary"
size={config.buttonSize || 'md'}
multiple={true}
accept="*/*"
>
Upload
</UploadButton>
<Button
onClick={() => hookData.handleSubmit?.()}
loading={hookData.isSubmitting || false}
disabled={buttonDisabled}
variant={buttonVariant}
size={config.buttonSize || 'md'}
icon={buttonIcon}
>
{resolveLanguageText(buttonLabel, t)}
</Button>
</div>
</div>
{/* Pending files display */}
{uploadedFiles.length > 0 && (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
padding: '8px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
fontSize: '0.875rem'
}}>
{uploadedFiles.map((file: { fileId: string; fileName: string }) => (
<div
key={file.fileId}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '4px 8px',
backgroundColor: 'white',
border: '1px solid #e0e0e0',
borderRadius: '4px',
fontSize: '0.8rem'
}}
>
<span>📎</span>
<span style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{file.fileName}
</span>
<button
onClick={() => hookData.handleFileRemove?.(file.fileId)}
disabled={hookData.isSubmitting || false}
style={{
background: 'none',
border: 'none',
cursor: hookData.isSubmitting ? 'not-allowed' : 'pointer',
padding: '0',
marginLeft: '4px',
fontSize: '1rem',
lineHeight: '1',
opacity: hookData.isSubmitting ? 0.5 : 1
}}
title="Remove file"
>
×
</button>
</div>
))}
</div>
)}
{/* Upload error display */}
{hookData.uploadError && (
<div style={{
padding: '8px 12px',
backgroundColor: '#ffebee',
color: '#c62828',
borderRadius: '4px',
fontSize: '0.875rem'
}}>
{hookData.uploadError}
</div>
)}
</div>
);
}
// Default layout without files (backward compatible) // Default layout without files (backward compatible)
return ( return (
<div key={content.id} style={{ <div key={content.id} style={{
@ -1682,19 +1809,17 @@ const PageRenderer: React.FC<PageRendererProps> = ({
} }
// If the page has drag drop config and hook data with handleUpload, integrate them // If the page has drag drop config and hook data with handleUpload or handleFileUpload, integrate them
if (hookData?.handleUpload) { const uploadHandler = hookData?.handleUpload || hookData?.handleFileUpload;
if (uploadHandler) {
return { return {
...pageData.dragDropConfig, ...pageData.dragDropConfig,
onDrop: async (files: File[]) => { onDrop: async (files: File[]) => {
try { try {
// Process each file through the hook's handleUpload function // Process each file through the hook's upload function
for (const file of files) { for (const file of files) {
if (hookData.handleUpload) { if (uploadHandler) {
await uploadHandler(file);
await hookData.handleUpload(file);
} }
} }
} catch (error) { } catch (error) {

View file

@ -66,6 +66,15 @@ export const chatbotPageData: GenericPageData = {
preload: true, preload: true,
moduleEnabled: true, moduleEnabled: true,
// Drag and drop configuration
dragDropConfig: {
enabled: true,
accept: '*/*', // Accept all file types
multiple: true, // Allow multiple files
overlayText: 'Drop files here to attach',
overlaySubtext: 'You can also click the upload button'
},
// Lifecycle hooks // Lifecycle hooks
onActivate: async () => { onActivate: async () => {
if (import.meta.env.DEV) console.log('Chatbot activated - state preserved'); if (import.meta.env.DEV) console.log('Chatbot activated - state preserved');

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { useApiRequest } from './useApi'; import { useApiRequest } from './useApi';
import api from '../api';
import { import {
startChatbotStreamApi, startChatbotStreamApi,
stopChatbotApi, stopChatbotApi,
@ -32,6 +33,13 @@ export function useChatbot() {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// File upload state
const [pendingFileIds, setPendingFileIds] = useState<string[]>([]);
const pendingFileIdsRef = useRef<string[]>([]); // Ref to avoid closure issues
const [uploadingFile, setUploadingFile] = useState<boolean>(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<Array<{ fileId: string; fileName: string }>>([]);
// Chat history state // Chat history state
const [threads, setThreads] = useState<ChatbotWorkflow[]>([]); const [threads, setThreads] = useState<ChatbotWorkflow[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null); const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
@ -340,6 +348,73 @@ export function useChatbot() {
setInputValue(value); setInputValue(value);
}, []); }, []);
// Handle file upload
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data?: any }> => {
setUploadError(null);
setUploadingFile(true);
try {
// Validate file before upload
if (!file || !file.name || file.name.trim() === '') {
throw new Error('Invalid file: File must have a valid name');
}
if (file.size === 0) {
throw new Error('Invalid file: File cannot be empty');
}
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/api/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
const fileData = response.data;
// Extract fileId from response
// Backend returns: { message: "...", file: { id: "...", ... }, duplicateType: "..." }
const fileId = fileData?.file?.id || fileData?.id || fileData?.fileId;
if (!fileId) {
console.error('Upload response structure:', fileData);
throw new Error('Upload failed: No file ID returned from server');
}
// Extract file name from response (use storedFileName if available, otherwise original fileName)
const fileName = fileData?.file?.fileName || fileData?.storedFileName || file.name;
// Add to pending file IDs and uploaded files list
setPendingFileIds(prev => {
const updated = [...prev, fileId];
pendingFileIdsRef.current = updated; // Keep ref in sync
return updated;
});
setUploadedFiles(prev => [...prev, { fileId, fileName }]);
return { success: true, data: fileData };
} catch (err: any) {
console.error('File upload failed:', err);
const errorMessage = err.message || 'Failed to upload file';
setUploadError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setUploadingFile(false);
}
}, []);
// Handle file remove (remove from pending list)
const handleFileRemove = useCallback((fileId: string) => {
setPendingFileIds(prev => {
const updated = prev.filter(id => id !== fileId);
pendingFileIdsRef.current = updated; // Keep ref in sync
return updated;
});
setUploadedFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
// Stop chatbot workflow // Stop chatbot workflow
const stopChatbot = useCallback(async () => { const stopChatbot = useCallback(async () => {
if (!workflowId || !isRunning) { if (!workflowId || !isRunning) {
@ -398,13 +473,32 @@ export function useChatbot() {
const abortController = new AbortController(); const abortController = new AbortController();
streamAbortControllerRef.current = abortController; streamAbortControllerRef.current = abortController;
// Prepare request body // Use ref to get current file IDs (avoids closure issues)
const fileIdsToSend = pendingFileIdsRef.current.length > 0
? pendingFileIdsRef.current
: pendingFileIds; // Fallback to state if ref is empty
// Log for debugging
console.log('[handleSubmit] pendingFileIds from state:', pendingFileIds);
console.log('[handleSubmit] pendingFileIds from ref:', pendingFileIdsRef.current);
console.log('[handleSubmit] fileIdsToSend:', fileIdsToSend);
const requestBody: StartChatbotRequest = { const requestBody: StartChatbotRequest = {
prompt: trimmedInput, prompt: trimmedInput,
userLanguage: 'en', userLanguage: 'en',
...(workflowId && { workflowId }) ...(workflowId && { workflowId })
}; };
// Always include listFileId if there are any files
if (fileIdsToSend.length > 0) {
requestBody.listFileId = fileIdsToSend;
console.log('[handleSubmit] Added listFileId to requestBody:', fileIdsToSend);
} else {
console.warn('[handleSubmit] No file IDs to send! Check if files were uploaded correctly.');
}
console.log('[handleSubmit] Final requestBody:', JSON.stringify(requestBody, null, 2));
// Track if workflow was created in this request // Track if workflow was created in this request
let workflowCreated = false; let workflowCreated = false;
@ -452,6 +546,10 @@ export function useChatbot() {
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
setIsRunning(false); setIsRunning(false);
setInputValue(''); // Clear input on completion setInputValue(''); // Clear input on completion
// Clear pending file IDs after successful submission (files are now part of conversation)
setPendingFileIds([]);
pendingFileIdsRef.current = []; // Clear ref too
setUploadedFiles([]);
// Clear thinking message on completion if no final message was received // Clear thinking message on completion if no final message was received
setTimeout(() => { setTimeout(() => {
clearThinkingMessage(); clearThinkingMessage();
@ -474,7 +572,7 @@ export function useChatbot() {
setIsSubmitting(false); setIsSubmitting(false);
streamAbortControllerRef.current = null; streamAbortControllerRef.current = null;
} }
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads]); }, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]);
// Delete a chatbot workflow // Delete a chatbot workflow
const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => { const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => {
@ -534,6 +632,9 @@ export function useChatbot() {
setSelectedThreadId(null); setSelectedThreadId(null);
setError(null); setError(null);
setInputValue(''); setInputValue('');
setPendingFileIds([]);
pendingFileIdsRef.current = [];
setUploadedFiles([]);
thinkingLogsRef.current = []; thinkingLogsRef.current = [];
thinkingMessageIdRef.current = null; thinkingMessageIdRef.current = null;
clearProcessedMessages(); clearProcessedMessages();
@ -554,6 +655,9 @@ export function useChatbot() {
setIsSubmitting(false); setIsSubmitting(false);
setError(null); setError(null);
setInputValue(''); setInputValue('');
setPendingFileIds([]);
pendingFileIdsRef.current = [];
setUploadedFiles([]);
thinkingLogsRef.current = []; thinkingLogsRef.current = [];
thinkingMessageIdRef.current = null; thinkingMessageIdRef.current = null;
clearProcessedMessages(); clearProcessedMessages();
@ -613,7 +717,16 @@ export function useChatbot() {
stopChatbot, stopChatbot,
resetChatbot, resetChatbot,
startNewChat, startNewChat,
cleanup cleanup,
// File upload interface
handleFileUpload,
handleUpload: handleFileUpload, // Alias for compatibility with DragDropOverlay
handleFileRemove,
pendingFileIds,
uploadedFiles,
uploadingFile,
uploadError
}; };
} }

View file

@ -96,7 +96,6 @@
.buttonPrimary:hover:not(:disabled) { .buttonPrimary:hover:not(:disabled) {
background: var(--button-primary-bg-hover); background: var(--button-primary-bg-hover);
transform: translateY(-1px);
} }
.buttonSecondary { .buttonSecondary {
@ -105,8 +104,8 @@
} }
.buttonSecondary:hover:not(:disabled) { .buttonSecondary:hover:not(:disabled) {
background: var(--button-secondary-bg-hover); background: var(--color-secondary-hover);
transform: translateY(-1px); color: white;
} }
.buttonDanger { .buttonDanger {
@ -116,7 +115,6 @@
.buttonDanger:hover:not(:disabled) { .buttonDanger:hover:not(:disabled) {
background: var(--button-danger-bg-hover); background: var(--button-danger-bg-hover);
transform: translateY(-1px);
} }
.buttonSuccess { .buttonSuccess {
@ -126,7 +124,6 @@
.buttonSuccess:hover:not(:disabled) { .buttonSuccess:hover:not(:disabled) {
background: var(--button-success-bg-hover); background: var(--button-success-bg-hover);
transform: translateY(-1px);
} }
.buttonWarning { .buttonWarning {
@ -136,7 +133,6 @@
.buttonWarning:hover:not(:disabled) { .buttonWarning:hover:not(:disabled) {
background: var(--button-warning-bg-hover); background: var(--button-warning-bg-hover);
transform: translateY(-1px);
} }
/* Button Sizes */ /* Button Sizes */