feat:weiter chatbot implementiert
This commit is contained in:
parent
eb280dbae1
commit
b0826a3f9a
9 changed files with 664 additions and 68 deletions
|
|
@ -73,12 +73,16 @@ export async function startChatbotStreamApi(
|
|||
): Promise<void> {
|
||||
try {
|
||||
// Prepare request body
|
||||
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const body: any = {
|
||||
prompt: requestBody.prompt,
|
||||
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
|
||||
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
|
||||
...(requestBody.metadata && { metadata: requestBody.metadata })
|
||||
};
|
||||
|
||||
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
|
||||
|
||||
// Add workflowId to query params if provided
|
||||
const url = requestBody.workflowId
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@
|
|||
|
||||
.actionButton:hover {
|
||||
background: var(--color-secondary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.actionButton:disabled {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function FormGeneratorControls({
|
|||
filterFocused,
|
||||
onFilterFocus,
|
||||
selectedCount,
|
||||
displayData: _displayData,
|
||||
displayData,
|
||||
onDeleteSingle,
|
||||
onDeleteMultiple,
|
||||
onRefresh,
|
||||
|
|
@ -76,6 +76,9 @@ export function FormGeneratorControls({
|
|||
}: FormGeneratorControlsProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Check if all items are selected
|
||||
const allItemsSelected = selectedCount > 0 && displayData.length > 0 && selectedCount === displayData.length;
|
||||
|
||||
// Filter fields that are filterable
|
||||
const filterableFields = fields.filter(field => {
|
||||
if (field.type === 'readonly') return false;
|
||||
|
|
@ -159,7 +162,8 @@ export function FormGeneratorControls({
|
|||
{/* Delete Controls - Show when items are selected */}
|
||||
{selectable && selectedCount > 0 && (
|
||||
<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
|
||||
onClick={onDeleteSingle}
|
||||
variant="primary"
|
||||
|
|
@ -169,14 +173,15 @@ export function FormGeneratorControls({
|
|||
{t('formgen.delete.single', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
{selectedCount > 1 && onDeleteMultiple && (
|
||||
{/* Show delete multiple if more than 1 selected OR all items are selected */}
|
||||
{(selectedCount > 1 || allItemsSelected) && onDeleteMultiple && (
|
||||
<Button
|
||||
onClick={onDeleteMultiple}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={FaTrash}
|
||||
>
|
||||
{selectedCount === displayData.length && displayData.length > 0
|
||||
{allItemsSelected
|
||||
? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString())
|
||||
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
max-height: none;
|
||||
flex: 1;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.emptyList {
|
||||
|
|
@ -107,6 +108,65 @@
|
|||
.headerButtonWrapper {
|
||||
margin-left: auto;
|
||||
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 {
|
||||
|
|
@ -165,7 +225,7 @@
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-medium-gray);
|
||||
|
|
@ -248,6 +308,17 @@
|
|||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
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 {
|
||||
|
|
@ -262,27 +333,69 @@
|
|||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.itemField:nth-child(2),
|
||||
.itemField:nth-child(3) {
|
||||
.itemField:first-child .fieldValue {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.metadataFields .itemField {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: 0.375rem;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.itemField:nth-child(2) {
|
||||
margin-right: 0.625rem;
|
||||
}
|
||||
|
||||
.itemField:nth-child(3) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.itemField:nth-child(2) .fieldValue,
|
||||
.itemField:nth-child(3) .fieldValue {
|
||||
.metadataFields .itemField .fieldValue {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary, #666);
|
||||
opacity: 0.8;
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -496,11 +609,9 @@
|
|||
@keyframes slideInFromTop {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './FormGeneratorList.module.css';
|
||||
import actionButtonStyles from '../ActionButtons/ActionButton.module.css';
|
||||
import {
|
||||
EditActionButton,
|
||||
DeleteActionButton,
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import TextField from '../../UiComponents/TextField/TextField';
|
||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||
import { IoIosTrash, IoIosCheckmark, IoIosClose } from "react-icons/io";
|
||||
import {
|
||||
isSelectType,
|
||||
isCheckboxType,
|
||||
|
|
@ -185,6 +187,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Check if backend pagination is supported
|
||||
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
|
||||
|
|
@ -243,6 +247,12 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
|
||||
// Data is already filtered, sorted, and paginated by the backend
|
||||
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
|
||||
const totalPages = useMemo(() => {
|
||||
|
|
@ -324,38 +334,164 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
}
|
||||
};
|
||||
|
||||
// Handle delete single item
|
||||
const handleDeleteSingle = (row: T, index: number) => {
|
||||
if (onDelete) {
|
||||
onDelete(row);
|
||||
if (selectedItems.has(index)) {
|
||||
const newSelected = new Set(selectedItems);
|
||||
newSelected.delete(index);
|
||||
setSelectedItems(newSelected);
|
||||
if (onItemSelect) {
|
||||
const selectedData = Array.from(newSelected).map(i => displayData[i]);
|
||||
onItemSelect(selectedData);
|
||||
// Handle delete multiple items click (show confirmation)
|
||||
const handleDeleteMultipleClick = () => {
|
||||
if (selectedItems.size === 0 || isDeleting) return;
|
||||
setIsConfirmingDelete(true);
|
||||
};
|
||||
|
||||
// Handle confirm delete
|
||||
const handleConfirmDelete = async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
setIsConfirmingDelete(false);
|
||||
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 delete multiple items
|
||||
const handleDeleteMultiple = () => {
|
||||
if (onDeleteMultiple && selectedItems.size > 0) {
|
||||
const selectedData = Array.from(selectedItems).map(i => displayData[i]);
|
||||
onDeleteMultiple(selectedData);
|
||||
setSelectedItems(new Set());
|
||||
onItemSelect?.([]);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isConfirmingDelete) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isConfirmingDelete]);
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setCurrentPageSize(newPageSize);
|
||||
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
|
||||
const formatFieldValue = (value: any, field: FieldConfig, row: T) => {
|
||||
if (field.formatter) {
|
||||
|
|
@ -459,10 +595,27 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
|
||||
// Render field input
|
||||
const renderFieldInput = (field: FieldConfig, value: any, row: T, _index: number) => {
|
||||
const isStatusField = field.key.toLowerCase().includes('status');
|
||||
|
||||
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 (
|
||||
<div className={styles.fieldValue} key={field.key}>
|
||||
<span className={`${styles.statusBadge} ${statusClass}`}>
|
||||
{formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fieldValue} key={field.key}>
|
||||
{formatFieldValue(value, field, row)}
|
||||
{formattedValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -515,7 +668,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
return (
|
||||
<div className={`${styles.formGeneratorList} ${className}`}>
|
||||
|
||||
{(searchable || filterable || (selectable && selectedItems.size > 0)) && (
|
||||
{(searchable || filterable) && selectedItems.size === 0 && (
|
||||
<FormGeneratorControls
|
||||
fields={detectedFields}
|
||||
searchTerm={searchTerm}
|
||||
|
|
@ -528,12 +681,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onFilterFocus={handleFilterFocus}
|
||||
selectedCount={selectedItems.size}
|
||||
displayData={displayData}
|
||||
onDeleteSingle={selectedItems.size === 1 && onDelete ? () => {
|
||||
const selectedIndex = Array.from(selectedItems)[0];
|
||||
const selectedRow = displayData[selectedIndex];
|
||||
handleDeleteSingle(selectedRow, selectedIndex);
|
||||
} : undefined}
|
||||
onDeleteMultiple={(selectedItems.size > 1 || (selectedItems.size === displayData.length && displayData.length > 0)) && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
||||
onDeleteSingle={undefined}
|
||||
onDeleteMultiple={undefined}
|
||||
onRefresh={onRefresh}
|
||||
searchable={searchable}
|
||||
filterable={filterable}
|
||||
|
|
@ -573,6 +722,47 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
{title && data.length > 0 && (
|
||||
<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 && (
|
||||
<div className={styles.headerButtonWrapper}>
|
||||
{headerButton}
|
||||
|
|
@ -713,17 +903,61 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
|
||||
{/* Fields */}
|
||||
<div className={styles.itemFields}>
|
||||
{detectedFields.map(field => {
|
||||
{detectedFields.map((field, fieldIndex) => {
|
||||
const cellValue = row[field.key];
|
||||
const customClassName = field.cellClassName ? field.cellClassName(cellValue, row) : '';
|
||||
|
||||
// First field (name) - render normally
|
||||
if (fieldIndex === 0) {
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`${styles.itemField} ${customClassName}`}
|
||||
>
|
||||
<label className={styles.fieldLabel}>{field.label}</label>
|
||||
{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)}
|
||||
{renderFieldInput(field, cellValue, row, fieldIndex)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// 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
|
||||
// Show prompt selector if user has permission to view/read prompts (even if no prompts exist yet)
|
||||
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)
|
||||
return (
|
||||
<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 (hookData?.handleUpload) {
|
||||
// If the page has drag drop config and hook data with handleUpload or handleFileUpload, integrate them
|
||||
const uploadHandler = hookData?.handleUpload || hookData?.handleFileUpload;
|
||||
if (uploadHandler) {
|
||||
return {
|
||||
...pageData.dragDropConfig,
|
||||
onDrop: async (files: File[]) => {
|
||||
|
||||
try {
|
||||
// Process each file through the hook's handleUpload function
|
||||
// Process each file through the hook's upload function
|
||||
for (const file of files) {
|
||||
if (hookData.handleUpload) {
|
||||
|
||||
await hookData.handleUpload(file);
|
||||
|
||||
if (uploadHandler) {
|
||||
await uploadHandler(file);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@ export const chatbotPageData: GenericPageData = {
|
|||
preload: 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
|
||||
onActivate: async () => {
|
||||
if (import.meta.env.DEV) console.log('Chatbot activated - state preserved');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { useApiRequest } from './useApi';
|
||||
import api from '../api';
|
||||
import {
|
||||
startChatbotStreamApi,
|
||||
stopChatbotApi,
|
||||
|
|
@ -32,6 +33,13 @@ export function useChatbot() {
|
|||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
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
|
||||
const [threads, setThreads] = useState<ChatbotWorkflow[]>([]);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
|
|
@ -340,6 +348,73 @@ export function useChatbot() {
|
|||
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
|
||||
const stopChatbot = useCallback(async () => {
|
||||
if (!workflowId || !isRunning) {
|
||||
|
|
@ -398,12 +473,31 @@ export function useChatbot() {
|
|||
const abortController = new 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 = {
|
||||
prompt: trimmedInput,
|
||||
userLanguage: 'en',
|
||||
...(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
|
||||
let workflowCreated = false;
|
||||
|
|
@ -452,6 +546,10 @@ export function useChatbot() {
|
|||
if (!abortController.signal.aborted) {
|
||||
setIsRunning(false);
|
||||
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
|
||||
setTimeout(() => {
|
||||
clearThinkingMessage();
|
||||
|
|
@ -474,7 +572,7 @@ export function useChatbot() {
|
|||
setIsSubmitting(false);
|
||||
streamAbortControllerRef.current = null;
|
||||
}
|
||||
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads]);
|
||||
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]);
|
||||
|
||||
// Delete a chatbot workflow
|
||||
const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => {
|
||||
|
|
@ -534,6 +632,9 @@ export function useChatbot() {
|
|||
setSelectedThreadId(null);
|
||||
setError(null);
|
||||
setInputValue('');
|
||||
setPendingFileIds([]);
|
||||
pendingFileIdsRef.current = [];
|
||||
setUploadedFiles([]);
|
||||
thinkingLogsRef.current = [];
|
||||
thinkingMessageIdRef.current = null;
|
||||
clearProcessedMessages();
|
||||
|
|
@ -554,6 +655,9 @@ export function useChatbot() {
|
|||
setIsSubmitting(false);
|
||||
setError(null);
|
||||
setInputValue('');
|
||||
setPendingFileIds([]);
|
||||
pendingFileIdsRef.current = [];
|
||||
setUploadedFiles([]);
|
||||
thinkingLogsRef.current = [];
|
||||
thinkingMessageIdRef.current = null;
|
||||
clearProcessedMessages();
|
||||
|
|
@ -613,7 +717,16 @@ export function useChatbot() {
|
|||
stopChatbot,
|
||||
resetChatbot,
|
||||
startNewChat,
|
||||
cleanup
|
||||
cleanup,
|
||||
|
||||
// File upload interface
|
||||
handleFileUpload,
|
||||
handleUpload: handleFileUpload, // Alias for compatibility with DragDropOverlay
|
||||
handleFileRemove,
|
||||
pendingFileIds,
|
||||
uploadedFiles,
|
||||
uploadingFile,
|
||||
uploadError
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@
|
|||
|
||||
.buttonPrimary:hover:not(:disabled) {
|
||||
background: var(--button-primary-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.buttonSecondary {
|
||||
|
|
@ -105,8 +104,8 @@
|
|||
}
|
||||
|
||||
.buttonSecondary:hover:not(:disabled) {
|
||||
background: var(--button-secondary-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-secondary-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.buttonDanger {
|
||||
|
|
@ -116,7 +115,6 @@
|
|||
|
||||
.buttonDanger:hover:not(:disabled) {
|
||||
background: var(--button-danger-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.buttonSuccess {
|
||||
|
|
@ -126,7 +124,6 @@
|
|||
|
||||
.buttonSuccess:hover:not(:disabled) {
|
||||
background: var(--button-success-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.buttonWarning {
|
||||
|
|
@ -136,7 +133,6 @@
|
|||
|
||||
.buttonWarning:hover:not(:disabled) {
|
||||
background: var(--button-warning-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
|
|
|||
Loading…
Reference in a new issue