Merge branch 'feat/chatbot' into int
This commit is contained in:
commit
7d794ef4cd
16 changed files with 4089 additions and 110 deletions
1473
package-lock.json
generated
1473
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -35,7 +35,9 @@
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-markdown": "^9.1.0",
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"xstate": "^5.20.1"
|
"xstate": "^5.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
314
src/api/chatbotApi.ts
Normal file
314
src/api/chatbotApi.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
import api from '../api';
|
||||||
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
|
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES & INTERFACES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface UserInputRequest {
|
||||||
|
input: string;
|
||||||
|
workflowId?: string;
|
||||||
|
files?: Array<{ id: string; name: string }>;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatbotWorkflow {
|
||||||
|
id: string;
|
||||||
|
mandateId: string;
|
||||||
|
status: string;
|
||||||
|
name?: string;
|
||||||
|
currentRound?: number;
|
||||||
|
currentTask?: number;
|
||||||
|
currentAction?: number;
|
||||||
|
startedAt?: number;
|
||||||
|
lastActivity?: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartChatbotRequest {
|
||||||
|
prompt: string;
|
||||||
|
listFileId?: string[];
|
||||||
|
userLanguage?: string;
|
||||||
|
workflowId?: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartChatbotResponse extends ChatbotWorkflow {
|
||||||
|
// Workflow object returned from start endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatDataItem {
|
||||||
|
type: 'message' | 'log' | 'stat' | 'document';
|
||||||
|
createdAt: number;
|
||||||
|
item: Message | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type for the request function passed to API functions
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
// Type for SSE event handler
|
||||||
|
export type SSEEventHandler = (item: ChatDataItem) => void;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API REQUEST FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new chatbot workflow or continue an existing one with SSE streaming
|
||||||
|
* Endpoint: POST /api/chatbot/start/stream
|
||||||
|
*
|
||||||
|
* @param requestBody - Request body with prompt and optional workflowId
|
||||||
|
* @param onEvent - Callback function called for each SSE event
|
||||||
|
* @param onError - Optional error callback
|
||||||
|
* @param onComplete - Optional completion callback
|
||||||
|
* @returns Promise that resolves when stream completes
|
||||||
|
*/
|
||||||
|
export async function startChatbotStreamApi(
|
||||||
|
requestBody: StartChatbotRequest,
|
||||||
|
onEvent: SSEEventHandler,
|
||||||
|
onError?: (error: Error) => void,
|
||||||
|
onComplete?: () => void
|
||||||
|
): 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
|
||||||
|
? `/api/chatbot/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
|
||||||
|
: '/api/chatbot/start/stream';
|
||||||
|
|
||||||
|
// Get base URL from api instance
|
||||||
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
const fullURL = baseURL + url;
|
||||||
|
|
||||||
|
// Prepare headers with authentication and CSRF token
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add auth token if available
|
||||||
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add CSRF token for POST requests
|
||||||
|
if (!getCSRFToken()) {
|
||||||
|
generateAndStoreCSRFToken();
|
||||||
|
}
|
||||||
|
addCSRFTokenToHeaders(headers);
|
||||||
|
|
||||||
|
// Use fetch for SSE streaming (POST with body)
|
||||||
|
const response = await fetch(fullURL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
credentials: 'include' // Include cookies for authentication
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode chunk and add to buffer
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete SSE messages
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6); // Remove 'data: ' prefix
|
||||||
|
if (jsonStr.trim()) {
|
||||||
|
const item: ChatDataItem = JSON.parse(jsonStr);
|
||||||
|
onEvent(item);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse SSE event:', line, parseError);
|
||||||
|
}
|
||||||
|
} else if (line.startsWith(':')) {
|
||||||
|
// Comment/keepalive line, ignore
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining buffer content
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const jsonStr = line.slice(6);
|
||||||
|
if (jsonStr.trim()) {
|
||||||
|
const item: ChatDataItem = JSON.parse(jsonStr);
|
||||||
|
onEvent(item);
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse SSE event:', line, parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error in startChatbotStreamApi:', error);
|
||||||
|
if (onError) {
|
||||||
|
onError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a running chatbot workflow
|
||||||
|
* Endpoint: POST /api/chatbot/{workflowId}/stop
|
||||||
|
*/
|
||||||
|
export async function stopChatbotApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<ChatbotWorkflow> {
|
||||||
|
const data = await request<ChatbotWorkflow>({
|
||||||
|
url: `/api/chatbot/${workflowId}/stop`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get chatbot threads/workflows
|
||||||
|
* Endpoint: GET /api/chatbot/threads
|
||||||
|
*/
|
||||||
|
export async function getChatbotThreadsApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
pagination?: { page?: number; pageSize?: number }
|
||||||
|
): Promise<{ items: ChatbotWorkflow[]; metadata: any }> {
|
||||||
|
const paginationParam = pagination ? JSON.stringify(pagination) : undefined;
|
||||||
|
const requestParams = paginationParam
|
||||||
|
? { pagination: paginationParam }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
console.log(`[getChatbotThreadsApi] Fetching threads with params:`, requestParams);
|
||||||
|
|
||||||
|
const data = await request<any>({
|
||||||
|
url: '/api/chatbot/threads',
|
||||||
|
method: 'get',
|
||||||
|
params: requestParams
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[getChatbotThreadsApi] Full response:`, JSON.stringify(data, null, 2));
|
||||||
|
console.log(`[getChatbotThreadsApi] Response structure:`, {
|
||||||
|
hasItems: !!data.items,
|
||||||
|
itemsLength: Array.isArray(data.items) ? data.items.length : 'not an array',
|
||||||
|
hasMetadata: !!data.metadata,
|
||||||
|
metadataKeys: data.metadata ? Object.keys(data.metadata) : []
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: Array.isArray(data.items) ? data.items : [],
|
||||||
|
metadata: data.metadata || {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific chatbot thread/workflow with its chat data
|
||||||
|
* Endpoint: GET /api/chatbot/threads?workflowId={id}
|
||||||
|
*
|
||||||
|
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
|
||||||
|
*
|
||||||
|
* @param request - API request function
|
||||||
|
* @param workflowId - ID of the workflow to fetch
|
||||||
|
* @returns Object containing workflow details and chatData with items array
|
||||||
|
*/
|
||||||
|
export async function getChatbotThreadApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
|
||||||
|
console.log(`[getChatbotThreadApi] Fetching thread with workflowId: ${workflowId}`);
|
||||||
|
|
||||||
|
const data = await request<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }>({
|
||||||
|
url: '/api/chatbot/threads',
|
||||||
|
method: 'get',
|
||||||
|
params: { workflowId }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[getChatbotThreadApi] Full response for workflowId ${workflowId}:`, JSON.stringify(data, null, 2));
|
||||||
|
console.log(`[getChatbotThreadApi] Response structure:`, {
|
||||||
|
hasWorkflow: !!data.workflow,
|
||||||
|
workflowKeys: data.workflow ? Object.keys(data.workflow) : [],
|
||||||
|
hasChatData: !!data.chatData,
|
||||||
|
hasItems: !!data.chatData?.items,
|
||||||
|
chatDataKeys: data.chatData ? Object.keys(data.chatData) : [],
|
||||||
|
itemsLength: Array.isArray(data.chatData?.items) ? data.chatData.items.length : 'not an array',
|
||||||
|
chatDataTypes: Array.isArray(data.chatData?.items) ? data.chatData.items.map(item => item?.type).filter(Boolean) : []
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
workflow: data.workflow,
|
||||||
|
chatData: data.chatData || { items: [] }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a chatbot workflow
|
||||||
|
* Endpoint: DELETE /api/chatbot/{workflowId}
|
||||||
|
*
|
||||||
|
* @param request - API request function
|
||||||
|
* @param workflowId - ID of the workflow to delete
|
||||||
|
* @returns Success status
|
||||||
|
*/
|
||||||
|
export async function deleteChatbotWorkflowApi(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
workflowId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await request<any>({
|
||||||
|
url: `/api/chatbot/${workflowId}`,
|
||||||
|
method: 'delete'
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting chatbot workflow:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,17 @@ 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}
|
||||||
>
|
>
|
||||||
{t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
{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>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
.formGeneratorList {
|
.formGeneratorList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List Container */
|
/* List Container */
|
||||||
.listContainer {
|
.listContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--color-primary);
|
border: none;
|
||||||
border-radius: 25px;
|
border-radius: 0;
|
||||||
background: var(--color-bg);
|
background: transparent;
|
||||||
max-height: calc(100vh - 400px);
|
max-height: none;
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
padding-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyList {
|
.emptyList {
|
||||||
|
|
@ -60,13 +65,15 @@
|
||||||
.listHeader {
|
.listHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 0.5rem;
|
||||||
padding: 12px 16px;
|
margin-bottom: 1rem;
|
||||||
border-bottom: 1px solid var(--color-primary);
|
padding-bottom: 0.75rem;
|
||||||
background: var(--color-bg);
|
border-bottom: 1px solid var(--color-medium-gray);
|
||||||
position: sticky;
|
background: transparent;
|
||||||
top: 0;
|
position: static;
|
||||||
z-index: 10;
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectAllCheckbox {
|
.selectAllCheckbox {
|
||||||
|
|
@ -80,6 +87,7 @@
|
||||||
border: 2px solid var(--color-primary);
|
border: 2px solid var(--color-primary);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectAllCheckbox:checked {
|
.selectAllCheckbox:checked {
|
||||||
|
|
@ -87,6 +95,86 @@
|
||||||
border-color: var(--color-secondary);
|
border-color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listTitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.sortControls {
|
.sortControls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
@ -128,29 +216,44 @@
|
||||||
.itemsList {
|
.itemsList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
padding: 16px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem {
|
.listItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: row;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
padding: 16px;
|
justify-content: space-between;
|
||||||
border: 1px solid var(--color-primary);
|
padding: 0.875rem 1rem;
|
||||||
border-radius: 20px;
|
background: transparent;
|
||||||
background: var(--color-bg);
|
border: none;
|
||||||
transition: all 0.2s ease;
|
border-top: 1px solid var(--color-medium-gray);
|
||||||
|
border-bottom: 1px solid var(--color-medium-gray);
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.5rem;
|
||||||
|
animation: slideInFromTop 0.3s ease-out;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem:first-child {
|
||||||
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem:hover {
|
.listItem:hover {
|
||||||
border-color: var(--color-secondary);
|
background: var(--color-highlight-gray);
|
||||||
box-shadow: 0 2px 8px rgba(var(--color-secondary-rgb), 0.1);
|
border-color: var(--color-medium-gray);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem.selected {
|
.listItem.selected {
|
||||||
background: rgba(var(--color-secondary-rgb), 0.1);
|
background: var(--color-highlight-gray);
|
||||||
border-color: var(--color-secondary);
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem.clickable {
|
.listItem.clickable {
|
||||||
|
|
@ -159,7 +262,7 @@
|
||||||
|
|
||||||
.itemSelect {
|
.itemSelect {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 4px;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemCheckbox {
|
.itemCheckbox {
|
||||||
|
|
@ -189,40 +292,126 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 4px;
|
padding-top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem:hover .itemActions,
|
||||||
|
.listItem.selected .itemActions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemFields {
|
.itemFields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
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 {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldLabel {
|
.itemField:first-child {
|
||||||
font-size: 12px;
|
gap: 0;
|
||||||
font-weight: 500;
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemField:first-child .fieldValue {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
opacity: 0.7;
|
}
|
||||||
font-family: var(--font-family);
|
|
||||||
|
.metadataFields .itemField {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldValue {
|
.fieldValue {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--font-family);
|
overflow: hidden;
|
||||||
padding: 8px 12px;
|
text-overflow: ellipsis;
|
||||||
background: var(--color-bg-disabled, rgba(0, 0, 0, 0.02));
|
white-space: nowrap;
|
||||||
border-radius: 12px;
|
line-height: 1.4;
|
||||||
border: 1px solid transparent;
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldInput {
|
.fieldInput {
|
||||||
|
|
@ -401,21 +590,29 @@
|
||||||
|
|
||||||
/* Custom scrollbar for list container */
|
/* Custom scrollbar for list container */
|
||||||
.listContainer::-webkit-scrollbar {
|
.listContainer::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 4px;
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.listContainer::-webkit-scrollbar-track {
|
.listContainer::-webkit-scrollbar-track {
|
||||||
background: var(--color-gray-disabled);
|
background: transparent;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.listContainer::-webkit-scrollbar-thumb {
|
.listContainer::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-gray);
|
background: var(--color-medium-gray);
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listContainer::-webkit-scrollbar-thumb:hover {
|
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-secondary);
|
background: var(--color-gray-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromTop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -49,6 +51,7 @@ export interface FormGeneratorListProps<T = any> {
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
isItemSelectable?: (row: T) => boolean;
|
isItemSelectable?: (row: T) => boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
actionButtons?: {
|
actionButtons?: {
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
||||||
onAction?: (row: T) => Promise<void> | void;
|
onAction?: (row: T) => Promise<void> | void;
|
||||||
|
|
@ -75,6 +78,7 @@ export interface FormGeneratorListProps<T = any> {
|
||||||
getItemDataAttributes?: (row: T, index: number) => Record<string, string>;
|
getItemDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||||
hookData?: any;
|
hookData?: any;
|
||||||
onFieldChange?: (row: T, fieldKey: string, value: any) => void; // For editable fields
|
onFieldChange?: (row: T, fieldKey: string, value: any) => void; // For editable fields
|
||||||
|
headerButton?: React.ReactNode; // Custom button in header
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormGeneratorList<T extends Record<string, any>>({
|
export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
|
|
@ -92,6 +96,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
selectable = true,
|
selectable = true,
|
||||||
isItemSelectable,
|
isItemSelectable,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
emptyMessage,
|
||||||
actionButtons = [],
|
actionButtons = [],
|
||||||
onDelete,
|
onDelete,
|
||||||
onDeleteMultiple,
|
onDeleteMultiple,
|
||||||
|
|
@ -99,7 +104,9 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
className = '',
|
className = '',
|
||||||
getItemDataAttributes,
|
getItemDataAttributes,
|
||||||
hookData,
|
hookData,
|
||||||
onFieldChange
|
onFieldChange,
|
||||||
|
title,
|
||||||
|
headerButton
|
||||||
}: FormGeneratorListProps<T>) {
|
}: FormGeneratorListProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
|
@ -180,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';
|
||||||
|
|
@ -239,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) {
|
||||||
|
|
@ -319,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) => {
|
||||||
|
|
@ -351,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) {
|
||||||
|
|
@ -454,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -510,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}
|
||||||
|
|
@ -523,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 && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
filterable={filterable}
|
filterable={filterable}
|
||||||
|
|
@ -562,6 +716,58 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
title={t('formgen.select.all', 'Select all items')}
|
title={t('formgen.select.all', 'Select all items')}
|
||||||
className={styles.selectAllCheckbox}
|
className={styles.selectAllCheckbox}
|
||||||
/>
|
/>
|
||||||
|
{title && (
|
||||||
|
<h3 className={styles.listTitle}>{title}</h3>
|
||||||
|
)}
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{sortable && (
|
{sortable && (
|
||||||
<div className={styles.sortControls}>
|
<div className={styles.sortControls}>
|
||||||
{detectedFields.map(field => (
|
{detectedFields.map(field => (
|
||||||
|
|
@ -586,7 +792,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
{/* List Items */}
|
{/* List Items */}
|
||||||
{displayData.length === 0 ? (
|
{displayData.length === 0 ? (
|
||||||
<div className={styles.emptyMessage}>
|
<div className={styles.emptyMessage}>
|
||||||
{t('formgen.empty', 'No data available')}
|
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.itemsList}>
|
<div className={styles.itemsList}>
|
||||||
|
|
@ -697,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, index)}
|
{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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Message } from '../MessagesTypes';
|
import { Message } from '../MessagesTypes';
|
||||||
import { formatTimestamp } from '../MessageUtils';
|
import { formatTimestamp } from '../MessageUtils';
|
||||||
import { DocumentItem, ActionInfo } from '../MessageParts';
|
import { DocumentItem, ActionInfo } from '../MessageParts';
|
||||||
|
|
@ -47,7 +49,62 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
{/* Message content */}
|
{/* Message content */}
|
||||||
{message.message && (
|
{message.message && (
|
||||||
<div className={styles.messageContent}>
|
<div className={styles.messageContent}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
className={styles.markdownContent}
|
||||||
|
components={{
|
||||||
|
// Custom styling for markdown elements
|
||||||
|
h1: ({node, ...props}) => <h1 className={styles.markdownH1} {...props} />,
|
||||||
|
h2: ({node, ...props}) => <h2 className={styles.markdownH2} {...props} />,
|
||||||
|
h3: ({node, ...props}) => <h3 className={styles.markdownH3} {...props} />,
|
||||||
|
h4: ({node, ...props}) => <h4 className={styles.markdownH4} {...props} />,
|
||||||
|
h5: ({node, ...props}) => <h5 className={styles.markdownH5} {...props} />,
|
||||||
|
h6: ({node, ...props}) => <h6 className={styles.markdownH6} {...props} />,
|
||||||
|
p: ({node, ...props}) => <p className={styles.markdownP} {...props} />,
|
||||||
|
ul: ({node, ...props}) => <ul className={styles.markdownUl} {...props} />,
|
||||||
|
ol: ({node, ...props}) => <ol className={styles.markdownOl} {...props} />,
|
||||||
|
li: ({node, ...props}) => <li className={styles.markdownLi} {...props} />,
|
||||||
|
table: ({node, ...props}) => <div className={styles.markdownTableWrapper}><table className={styles.markdownTable} {...props} /></div>,
|
||||||
|
thead: ({node, ...props}) => <thead className={styles.markdownThead} {...props} />,
|
||||||
|
tbody: ({node, ...props}) => <tbody className={styles.markdownTbody} {...props} />,
|
||||||
|
tr: ({node, ...props}) => <tr className={styles.markdownTr} {...props} />,
|
||||||
|
th: ({node, ...props}) => <th className={styles.markdownTh} data-in-table="true" {...props} />,
|
||||||
|
td: ({node, ...props}) => <td className={styles.markdownTd} data-in-table="true" {...props} />,
|
||||||
|
code: ({node, inline, ...props}: any) =>
|
||||||
|
inline ? (
|
||||||
|
<code className={styles.markdownCodeInline} {...props} />
|
||||||
|
) : (
|
||||||
|
<code className={styles.markdownCodeBlock} {...props} />
|
||||||
|
),
|
||||||
|
pre: ({node, ...props}) => <pre className={styles.markdownPre} {...props} />,
|
||||||
|
blockquote: ({node, ...props}) => <blockquote className={styles.markdownBlockquote} {...props} />,
|
||||||
|
strong: ({node, ...props}) => <strong className={styles.markdownStrong} {...props} />,
|
||||||
|
em: ({node, ...props}) => <em className={styles.markdownEm} {...props} />,
|
||||||
|
a: ({node, ...props}: any) => {
|
||||||
|
// Check if link is inside a table by checking parent chain
|
||||||
|
// In react-markdown, we need to check the node structure
|
||||||
|
const isInTable = (node: any): boolean => {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current) {
|
||||||
|
if (current.type === 'tableCell' || current.type === 'tableRow' || current.type === 'table') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInTable(node)) {
|
||||||
|
// Render as plain text if inside table
|
||||||
|
return <span className={styles.markdownLinkText}>{props.children}</span>;
|
||||||
|
}
|
||||||
|
return <a className={styles.markdownLink} {...props} />;
|
||||||
|
},
|
||||||
|
hr: ({node, ...props}) => <hr className={styles.markdownHr} {...props} />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{message.message}
|
{message.message}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,6 @@
|
||||||
.messageContent {
|
.messageContent {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,6 +173,322 @@
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Styling */
|
||||||
|
.markdownContent {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.markdownH1,
|
||||||
|
.markdownH2,
|
||||||
|
.markdownH3,
|
||||||
|
.markdownH4,
|
||||||
|
.markdownH5,
|
||||||
|
.markdownH6 {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH1 {
|
||||||
|
font-size: 24px;
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH2 {
|
||||||
|
font-size: 20px;
|
||||||
|
border-bottom: 1px solid var(--color-gray-disabled);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH4 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH5 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH6 {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownH1,
|
||||||
|
.messageUser .markdownH2,
|
||||||
|
.messageUser .markdownH3,
|
||||||
|
.messageUser .markdownH4,
|
||||||
|
.messageUser .markdownH5,
|
||||||
|
.messageUser .markdownH6 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownH1 {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownH2 {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownH1,
|
||||||
|
.messageAssistant .markdownH2,
|
||||||
|
.messageAssistant .markdownH3,
|
||||||
|
.messageAssistant .markdownH4,
|
||||||
|
.messageAssistant .markdownH5,
|
||||||
|
.messageAssistant .markdownH6 {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownH1 {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownH2 {
|
||||||
|
border-bottom-color: var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraphs */
|
||||||
|
.markdownP {
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line; /* Preserve line breaks */
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownP:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownP:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.markdownUl,
|
||||||
|
.markdownOl {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownLi {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownUl .markdownLi {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownOl .markdownLi {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.markdownTableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTable {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownTable {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownThead {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownThead {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTh {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTh {
|
||||||
|
color: white;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownTh {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTd {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTd {
|
||||||
|
color: white;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownTd {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom-color: var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTr:last-child .markdownTd {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.markdownCodeInline {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownCodeInline {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownCodeInline {
|
||||||
|
background-color: var(--color-highlight-gray);
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPre {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownPre {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownPre {
|
||||||
|
background-color: var(--color-highlight-gray);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownCodeBlock {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquote */
|
||||||
|
.markdownBlockquote {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownBlockquote {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-left-color: rgba(255, 255, 255, 0.5);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownBlockquote {
|
||||||
|
background-color: var(--color-highlight-gray);
|
||||||
|
border-left-color: var(--color-primary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strong and Emphasis */
|
||||||
|
.markdownStrong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownStrong {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownStrong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownEm {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.markdownLink {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownLink:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownLink {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links in tables - rendered as text */
|
||||||
|
.markdownLinkText {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownLinkText {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownLinkText {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal Rule */
|
||||||
|
.markdownHr {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownHr {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownHr {
|
||||||
|
border-top-color: var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
.messageTimestamp {
|
.messageTimestamp {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { GenericPageData, PageButton, PageContent, resolveLanguageText, Settings
|
||||||
import { FormGenerator } from '../../components/FormGenerator';
|
import { FormGenerator } from '../../components/FormGenerator';
|
||||||
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
|
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
|
||||||
|
import { FormGeneratorList, FieldConfig } from '../../components/FormGenerator';
|
||||||
|
import { LuPlus } from 'react-icons/lu';
|
||||||
|
import type { ChatbotWorkflow } from '../../api/chatbotApi';
|
||||||
import { Popup } from '../../components/UiComponents/Popup';
|
import { Popup } from '../../components/UiComponents/Popup';
|
||||||
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
||||||
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
||||||
|
|
@ -200,11 +203,57 @@ const ContentRenderer: React.FC<{
|
||||||
return null; // Or return a loading indicator if desired
|
return null; // Or return a loading indicator if desired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a dashboard layout pattern: messages, log, inputForm
|
// Check if this is a chatbot layout pattern: chatHistory, messages, inputForm
|
||||||
|
const hasChatHistory = visibleContents.some(c => c.type === 'chatHistory');
|
||||||
const hasMessages = visibleContents.some(c => c.type === 'messages');
|
const hasMessages = visibleContents.some(c => c.type === 'messages');
|
||||||
const hasLog = visibleContents.some(c => c.type === 'log');
|
|
||||||
const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
|
const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
|
||||||
const isDashboardLayout = hasMessages && hasLog && hasInputForm;
|
const isChatbotLayout = hasChatHistory && hasMessages && hasInputForm;
|
||||||
|
|
||||||
|
// Check if this is a dashboard layout pattern: messages, log, inputForm
|
||||||
|
const hasLog = visibleContents.some(c => c.type === 'log');
|
||||||
|
const isDashboardLayout = hasMessages && hasLog && hasInputForm && !isChatbotLayout;
|
||||||
|
|
||||||
|
if (isChatbotLayout) {
|
||||||
|
// Render chatbot two-column layout
|
||||||
|
const chatHistoryContent = visibleContents.find(c => c.type === 'chatHistory');
|
||||||
|
const messagesContent = visibleContents.find(c => c.type === 'messages');
|
||||||
|
const inputFormContent = visibleContents.find(c => c.type === 'inputForm');
|
||||||
|
const otherContents = visibleContents.filter(c =>
|
||||||
|
c.type !== 'chatHistory' && c.type !== 'messages' && c.type !== 'inputForm'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.chatbotTwoColumnLayout}>
|
||||||
|
{/* Left column: Chat History */}
|
||||||
|
{chatHistoryContent && (
|
||||||
|
<div className={styles.chatbotHistoryColumn}>
|
||||||
|
{renderContent(chatHistoryContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Right column: Messages and Input Form */}
|
||||||
|
<div className={styles.chatbotChatColumn}>
|
||||||
|
{messagesContent && (
|
||||||
|
<div className={styles.chatbotMessagesCell}>
|
||||||
|
{renderContent(messagesContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{inputFormContent && (
|
||||||
|
<div className={styles.chatbotInputCell}>
|
||||||
|
{renderContent(inputFormContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Render any other content sections */}
|
||||||
|
{otherContents.map((content, index) => (
|
||||||
|
<React.Fragment key={content.id || `content-${content.type}-${index}`}>
|
||||||
|
{renderContent(content)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isDashboardLayout) {
|
if (isDashboardLayout) {
|
||||||
// Render dashboard grid layout
|
// Render dashboard grid layout
|
||||||
|
|
@ -957,7 +1006,12 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
const buttonVariant = isRunning
|
const buttonVariant = isRunning
|
||||||
? (config.stopButtonVariant || config.buttonVariant || 'primary')
|
? (config.stopButtonVariant || config.buttonVariant || 'primary')
|
||||||
: (config.buttonVariant || 'primary');
|
: (config.buttonVariant || 'primary');
|
||||||
const buttonDisabled = hookData.isSubmitting || (!isRunning && !hookData.inputValue?.trim());
|
// Button disabled logic:
|
||||||
|
// - Always enabled when running (to allow stopping), unless submitting
|
||||||
|
// - When not running, disabled if submitting or input is empty
|
||||||
|
const buttonDisabled = isRunning
|
||||||
|
? hookData.isSubmitting // When running, only disable if submitting
|
||||||
|
: (hookData.isSubmitting || !hookData.inputValue?.trim()); // When not running, disable if submitting or input empty
|
||||||
|
|
||||||
// Handle Enter key press
|
// Handle Enter key press
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -967,9 +1021,14 @@ 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)
|
||||||
|
// Also check if file upload is enabled in config (default: true)
|
||||||
|
const showFileUpload = config.showFileUpload !== false; // Default to true if not specified
|
||||||
|
const hasChatbotFileUpload = showFileUpload && !!(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 &&
|
||||||
|
|
@ -1242,6 +1301,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={{
|
||||||
|
|
@ -1465,6 +1648,162 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'chatHistory': {
|
||||||
|
const chatHistoryConfig = content.chatHistoryConfig || {};
|
||||||
|
const threads: ChatbotWorkflow[] = (hookData as any)?.threads || [];
|
||||||
|
const selectedThreadId = (hookData as any)?.selectedThreadId || null;
|
||||||
|
const selectThread = (hookData as any)?.selectThread;
|
||||||
|
const startNewChat = (hookData as any)?.startNewChat;
|
||||||
|
const threadsLoading = (hookData as any)?.threadsLoading || false;
|
||||||
|
const threadsError = (hookData as any)?.threadsError || null;
|
||||||
|
|
||||||
|
if (!selectThread) {
|
||||||
|
console.warn('ChatHistoryList requires selectThread method from hookData');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date function for relative time display
|
||||||
|
const formatDate = (timestamp?: number): string => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
// Format as date
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get thread preview function
|
||||||
|
const getThreadPreview = (thread: ChatbotWorkflow): string => {
|
||||||
|
if (thread.name) return thread.name;
|
||||||
|
return `Chat ${thread.id.slice(0, 8)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Field configuration for ChatbotWorkflow
|
||||||
|
// First field: Thread preview (uses name field but formats it)
|
||||||
|
// Second field: Date (uses lastActivity field but formats it)
|
||||||
|
// Third field: Status
|
||||||
|
const fields: FieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Thread',
|
||||||
|
type: 'text',
|
||||||
|
formatter: (value: any, row: ChatbotWorkflow) => getThreadPreview(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastActivity',
|
||||||
|
label: 'Date',
|
||||||
|
type: 'timestamp',
|
||||||
|
formatter: (value: any, row: ChatbotWorkflow) => {
|
||||||
|
const timestamp = row.lastActivity || row.startedAt;
|
||||||
|
return formatDate(timestamp);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'text',
|
||||||
|
formatter: (value: any, row: ChatbotWorkflow) => row.status || ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Handle error state
|
||||||
|
if (threadsError) {
|
||||||
|
return (
|
||||||
|
<div key={content.id} className={styles.chatHistorySection}>
|
||||||
|
<div className={styles.chatHistoryHeader}>
|
||||||
|
<h3 className={styles.chatHistoryTitle}>Chat History</h3>
|
||||||
|
{startNewChat && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={LuPlus}
|
||||||
|
onClick={startNewChat}
|
||||||
|
title="New Chat"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chatHistoryError}>
|
||||||
|
{threadsError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = chatHistoryConfig.emptyMessage
|
||||||
|
? resolveLanguageText(chatHistoryConfig.emptyMessage, t)
|
||||||
|
: 'No chat history yet. Start a conversation to see it here.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={content.id} className={styles.chatHistorySection}>
|
||||||
|
<FormGeneratorList<ChatbotWorkflow>
|
||||||
|
data={threads}
|
||||||
|
fields={fields}
|
||||||
|
title="Chat History"
|
||||||
|
searchable={false}
|
||||||
|
filterable={false}
|
||||||
|
sortable={false}
|
||||||
|
pagination={false}
|
||||||
|
selectable={true}
|
||||||
|
loading={threadsLoading}
|
||||||
|
onItemClick={(row) => selectThread(row.id)}
|
||||||
|
onItemSelect={(selectedRows) => {
|
||||||
|
// Handle multiselect - select first item when multiple selected
|
||||||
|
if (selectedRows.length > 0 && selectedRows[0]) {
|
||||||
|
selectThread(selectedRows[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeleteMultiple={(selectedRows) => {
|
||||||
|
// Handle bulk delete
|
||||||
|
selectedRows.forEach(row => {
|
||||||
|
// Delete logic handled by action button
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
getItemDataAttributes={(row) => ({
|
||||||
|
'selected-thread-id': selectedThreadId === row.id ? 'true' : 'false',
|
||||||
|
'thread-id': row.id
|
||||||
|
})}
|
||||||
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'delete',
|
||||||
|
idField: 'id',
|
||||||
|
operationName: 'handleDelete',
|
||||||
|
loadingStateName: 'deletingItems',
|
||||||
|
onAction: async (row) => {
|
||||||
|
// If deleted thread was selected, start new chat
|
||||||
|
if (selectedThreadId === row.id && startNewChat) {
|
||||||
|
startNewChat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
hookData={hookData}
|
||||||
|
className={styles.chatHistoryList}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
headerButton={startNewChat ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={LuPlus}
|
||||||
|
onClick={startNewChat}
|
||||||
|
title="New Chat"
|
||||||
|
className={styles.chatHistoryNewButton}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1477,19 +1816,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) {
|
||||||
|
|
|
||||||
80
src/core/PageManager/data/pages/chatbot.ts
Normal file
80
src/core/PageManager/data/pages/chatbot.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { GenericPageData } from '../../pageInterface';
|
||||||
|
import { LuMessageSquare } from 'react-icons/lu';
|
||||||
|
import { IoMdSend } from 'react-icons/io';
|
||||||
|
import { MdStop } from 'react-icons/md';
|
||||||
|
import { createChatbotHook } from '../../../../hooks/useChatbot';
|
||||||
|
|
||||||
|
export const chatbotPageData: GenericPageData = {
|
||||||
|
id: 'start-chatbot',
|
||||||
|
path: 'start/chatbot',
|
||||||
|
name: 'Chatbot',
|
||||||
|
description: 'Simple chatbot interface for conversations',
|
||||||
|
|
||||||
|
// Parent page
|
||||||
|
parentPath: 'start',
|
||||||
|
showInSidebar: true,
|
||||||
|
|
||||||
|
// Visual
|
||||||
|
icon: LuMessageSquare,
|
||||||
|
title: 'Chatbot',
|
||||||
|
subtitle: 'Chat with AI assistant',
|
||||||
|
|
||||||
|
// No header buttons (simpler than dashboard)
|
||||||
|
headerButtons: [],
|
||||||
|
|
||||||
|
// Content sections
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
id: 'chatbot-history',
|
||||||
|
type: 'chatHistory',
|
||||||
|
chatHistoryConfig: {
|
||||||
|
emptyMessage: 'No chat history yet. Start a conversation to see it here.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chatbot-messages',
|
||||||
|
type: 'messages',
|
||||||
|
messagesConfig: {
|
||||||
|
variant: 'chat',
|
||||||
|
showDocuments: true,
|
||||||
|
showMetadata: false,
|
||||||
|
showProgress: false,
|
||||||
|
emptyMessage: 'No messages yet. Start a conversation to see messages here.'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chatbot-input',
|
||||||
|
type: 'inputForm',
|
||||||
|
inputFormConfig: {
|
||||||
|
hookFactory: createChatbotHook,
|
||||||
|
placeholder: 'Type your message here...',
|
||||||
|
buttonLabel: 'Send',
|
||||||
|
stopButtonLabel: 'Stop',
|
||||||
|
buttonIcon: IoMdSend,
|
||||||
|
stopButtonIcon: MdStop,
|
||||||
|
buttonVariant: 'primary',
|
||||||
|
stopButtonVariant: 'danger',
|
||||||
|
buttonSize: 'md',
|
||||||
|
textFieldSize: 'md',
|
||||||
|
showFileUpload: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Page behavior
|
||||||
|
persistent: true,
|
||||||
|
preserveState: true,
|
||||||
|
preload: true,
|
||||||
|
moduleEnabled: true,
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Lifecycle hooks
|
||||||
|
onActivate: async () => {
|
||||||
|
if (import.meta.env.DEV) console.log('Chatbot activated - state preserved');
|
||||||
|
},
|
||||||
|
onDeactivate: async () => {
|
||||||
|
if (import.meta.env.DEV) console.log('Chatbot deactivated - keeping state');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ export { speechPageData } from './speech';
|
||||||
export { settingsPageData } from './settings';
|
export { settingsPageData } from './settings';
|
||||||
export { pekPageData } from './pek';
|
export { pekPageData } from './pek';
|
||||||
export { pekTablesPageData } from './pek-tables';
|
export { pekTablesPageData } from './pek-tables';
|
||||||
|
export { chatbotPageData } from './chatbot';
|
||||||
|
|
||||||
// Import all page data
|
// Import all page data
|
||||||
import { dashboardPageData } from './dashboard';
|
import { dashboardPageData } from './dashboard';
|
||||||
|
|
@ -21,6 +22,7 @@ import { speechPageData } from './speech';
|
||||||
import { settingsPageData } from './settings';
|
import { settingsPageData } from './settings';
|
||||||
import { pekPageData } from './pek';
|
import { pekPageData } from './pek';
|
||||||
import { pekTablesPageData } from './pek-tables';
|
import { pekTablesPageData } from './pek-tables';
|
||||||
|
import { chatbotPageData } from './chatbot';
|
||||||
|
|
||||||
// Array of all page data
|
// Array of all page data
|
||||||
export const allPageData = [
|
export const allPageData = [
|
||||||
|
|
@ -34,6 +36,7 @@ export const allPageData = [
|
||||||
settingsPageData,
|
settingsPageData,
|
||||||
pekPageData,
|
pekPageData,
|
||||||
pekTablesPageData,
|
pekTablesPageData,
|
||||||
|
chatbotPageData,
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ export interface InputFormConfig {
|
||||||
stopButtonVariant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning'; // Variant for stop button
|
stopButtonVariant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning'; // Variant for stop button
|
||||||
buttonSize?: 'sm' | 'md' | 'lg';
|
buttonSize?: 'sm' | 'md' | 'lg';
|
||||||
textFieldSize?: 'sm' | 'md' | 'lg';
|
textFieldSize?: 'sm' | 'md' | 'lg';
|
||||||
|
showFileUpload?: boolean; // Whether to show file upload button (default: true if hook provides file upload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings field configuration
|
// Settings field configuration
|
||||||
|
|
@ -123,7 +124,7 @@ export interface SettingsConfig {
|
||||||
// Content section for paragraphs
|
// Content section for paragraphs
|
||||||
export interface PageContent {
|
export interface PageContent {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log';
|
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'chatHistory';
|
||||||
content?: string | LanguageText; // Optional for dividers
|
content?: string | LanguageText; // Optional for dividers
|
||||||
level?: number; // For headings (1-6)
|
level?: number; // For headings (1-6)
|
||||||
items?: (string | LanguageText)[]; // For lists
|
items?: (string | LanguageText)[]; // For lists
|
||||||
|
|
@ -148,6 +149,10 @@ export interface PageContent {
|
||||||
logConfig?: {
|
logConfig?: {
|
||||||
emptyMessage?: string | LanguageText;
|
emptyMessage?: string | LanguageText;
|
||||||
};
|
};
|
||||||
|
// Chat history-specific properties
|
||||||
|
chatHistoryConfig?: {
|
||||||
|
emptyMessage?: string | LanguageText;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic hook interface for data fetching
|
// Generic hook interface for data fetching
|
||||||
|
|
|
||||||
781
src/hooks/useChatbot.ts
Normal file
781
src/hooks/useChatbot.ts
Normal file
|
|
@ -0,0 +1,781 @@
|
||||||
|
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
import api from '../api';
|
||||||
|
import {
|
||||||
|
startChatbotStreamApi,
|
||||||
|
stopChatbotApi,
|
||||||
|
getChatbotThreadsApi,
|
||||||
|
getChatbotThreadApi,
|
||||||
|
deleteChatbotWorkflowApi,
|
||||||
|
type ChatDataItem,
|
||||||
|
type StartChatbotRequest,
|
||||||
|
type ChatbotWorkflow
|
||||||
|
} from '../api/chatbotApi';
|
||||||
|
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
// Simple sort function for messages
|
||||||
|
const sortMessages = (a: Message, b: Message) => {
|
||||||
|
if (a.publishedAt !== undefined && b.publishedAt !== undefined) {
|
||||||
|
return a.publishedAt - b.publishedAt;
|
||||||
|
}
|
||||||
|
if (a.publishedAt !== undefined) return -1;
|
||||||
|
if (b.publishedAt !== undefined) return 1;
|
||||||
|
if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) {
|
||||||
|
return a.sequenceNr - b.sequenceNr;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChatbot() {
|
||||||
|
const [inputValue, setInputValue] = useState<string>('');
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
||||||
|
const [isRunning, setIsRunning] = useState<boolean>(false);
|
||||||
|
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);
|
||||||
|
const [threadsLoading, setThreadsLoading] = useState<boolean>(false);
|
||||||
|
const [threadsError, setThreadsError] = useState<string | null>(null);
|
||||||
|
const [deletingThreads, setDeletingThreads] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const streamAbortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const processedMessageIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const thinkingMessageIdRef = useRef<string | null>(null);
|
||||||
|
const thinkingLogsRef = useRef<string[]>([]); // Use ref instead of state to avoid batching
|
||||||
|
const logQueueRef = useRef<string[]>([]); // Queue for logs to process one by one
|
||||||
|
const isProcessingLogsRef = useRef<boolean>(false); // Flag to prevent concurrent processing
|
||||||
|
const processedLogsRef = useRef<Set<string>>(new Set()); // Track processed logs to prevent duplicates
|
||||||
|
|
||||||
|
// Clear processed message IDs when workflow changes
|
||||||
|
const clearProcessedMessages = useCallback(() => {
|
||||||
|
processedMessageIdsRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear thinking message when a new assistant message arrives
|
||||||
|
const clearThinkingMessage = useCallback(() => {
|
||||||
|
// Clear log queue and stop processing
|
||||||
|
logQueueRef.current = [];
|
||||||
|
isProcessingLogsRef.current = false;
|
||||||
|
processedLogsRef.current.clear(); // Clear processed logs tracking
|
||||||
|
|
||||||
|
// Reset thinking message refs
|
||||||
|
const thinkingId = thinkingMessageIdRef.current;
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
|
||||||
|
// Remove ALL thinking messages (not just the one with current ID)
|
||||||
|
// This handles cases where multiple thinking messages might exist
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
return prevMessages.filter(m => m.status !== 'thinking');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Process logs from queue one by one (progressive display)
|
||||||
|
const processLogQueue = useCallback(() => {
|
||||||
|
if (isProcessingLogsRef.current || logQueueRef.current.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessingLogsRef.current = true;
|
||||||
|
const logMessage = logQueueRef.current.shift()!;
|
||||||
|
|
||||||
|
// Add log to accumulated logs
|
||||||
|
thinkingLogsRef.current = [...thinkingLogsRef.current, logMessage];
|
||||||
|
|
||||||
|
// Get or create thinking message ID
|
||||||
|
const thinkingId = thinkingMessageIdRef.current || `thinking-${Date.now()}`;
|
||||||
|
thinkingMessageIdRef.current = thinkingId;
|
||||||
|
|
||||||
|
// Create/update thinking message with all accumulated logs
|
||||||
|
// Format logs as Markdown list items so each log appears on a separate line
|
||||||
|
const formattedLogs = thinkingLogsRef.current
|
||||||
|
.map(log => `- ${log.trim()}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const thinkingMessage: Message = {
|
||||||
|
id: thinkingId,
|
||||||
|
workflowId: workflowId || '',
|
||||||
|
role: 'assistant',
|
||||||
|
message: formattedLogs,
|
||||||
|
publishedAt: Date.now() - 1,
|
||||||
|
status: 'thinking'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update messages immediately
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
// Remove ALL thinking messages first (to prevent duplicates from previous workflows)
|
||||||
|
const filtered = prevMessages.filter(m => m.status !== 'thinking');
|
||||||
|
// Add updated thinking message
|
||||||
|
const updated = [...filtered, thinkingMessage];
|
||||||
|
return updated.sort(sortMessages);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process next log after a small delay (progressive display)
|
||||||
|
if (logQueueRef.current.length > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
isProcessingLogsRef.current = false;
|
||||||
|
processLogQueue();
|
||||||
|
}, 50); // Small delay between logs for progressive display
|
||||||
|
} else {
|
||||||
|
isProcessingLogsRef.current = false;
|
||||||
|
}
|
||||||
|
}, [workflowId]);
|
||||||
|
|
||||||
|
// Add a single log to thinking message (progressive display)
|
||||||
|
const addLogToThinkingMessage = useCallback((logMessage: string, createdAt?: number) => {
|
||||||
|
// Create a unique key for this log message to detect duplicates
|
||||||
|
// Use content + createdAt timestamp if available, otherwise use current time
|
||||||
|
const timestamp = createdAt || Date.now();
|
||||||
|
const logKey = `${logMessage.trim()}_${timestamp}`;
|
||||||
|
|
||||||
|
// Skip if this log was already processed
|
||||||
|
if (processedLogsRef.current.has(logKey)) {
|
||||||
|
console.log('[useChatbot] Skipping duplicate log:', logMessage.substring(0, 50) + '...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
processedLogsRef.current.add(logKey);
|
||||||
|
|
||||||
|
// Add log to queue
|
||||||
|
logQueueRef.current.push(logMessage);
|
||||||
|
|
||||||
|
// Start processing if not already processing
|
||||||
|
if (!isProcessingLogsRef.current) {
|
||||||
|
processLogQueue();
|
||||||
|
}
|
||||||
|
}, [processLogQueue]);
|
||||||
|
|
||||||
|
// Process SSE event and update messages
|
||||||
|
const processChatDataItem = useCallback((item: ChatDataItem) => {
|
||||||
|
// Log the actual streamed response for debugging
|
||||||
|
console.log('[useChatbot] Streamed item:', {
|
||||||
|
type: item.type,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
item: item.item
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item.type === 'log' && item.item) {
|
||||||
|
// Process log item - add to thinking message one at a time
|
||||||
|
const logData = item.item as any;
|
||||||
|
const logMessage = logData.message || logData.text || '';
|
||||||
|
|
||||||
|
console.log('[useChatbot] Processing log:', {
|
||||||
|
message: logMessage.substring(0, 100) + (logMessage.length > 100 ? '...' : ''),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
fullItem: item
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logMessage) {
|
||||||
|
// Add log immediately (progressive display) with createdAt for deduplication
|
||||||
|
addLogToThinkingMessage(logMessage, item.createdAt);
|
||||||
|
}
|
||||||
|
} else if (item.type === 'message' && item.item) {
|
||||||
|
const messageData = item.item as any;
|
||||||
|
|
||||||
|
// Ensure message has required fields
|
||||||
|
if (!messageData.id) {
|
||||||
|
console.warn('⚠️ Invalid message item (missing id):', messageData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an assistant message that should clear thinking message
|
||||||
|
const messageRole = messageData.role?.toLowerCase();
|
||||||
|
const isAssistantMessage = messageRole === 'assistant' || messageRole === 'ai' || messageRole === 'system';
|
||||||
|
|
||||||
|
// ALWAYS clear thinking message when ANY message arrives (not just assistant)
|
||||||
|
// The thinking message should disappear when the real message comes
|
||||||
|
const thinkingId = thinkingMessageIdRef.current;
|
||||||
|
|
||||||
|
// Check if we've already processed this message
|
||||||
|
const messageId = messageData.id;
|
||||||
|
|
||||||
|
// Always clear thinking messages when a real message arrives
|
||||||
|
clearThinkingMessage();
|
||||||
|
|
||||||
|
if (processedMessageIdsRef.current.has(messageId)) {
|
||||||
|
// Update existing message
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
const existingIndex = prevMessages.findIndex(m => m.id === messageId);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const updated = [...prevMessages];
|
||||||
|
updated[existingIndex] = messageData as Message;
|
||||||
|
return updated.sort(sortMessages);
|
||||||
|
}
|
||||||
|
return prevMessages;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add new message
|
||||||
|
processedMessageIdsRef.current.add(messageId);
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
// Add new message (thinking messages already cleared by clearThinkingMessage)
|
||||||
|
const updated = [...prevMessages, messageData as Message];
|
||||||
|
return updated.sort(sortMessages);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [addLogToThinkingMessage]);
|
||||||
|
|
||||||
|
// Load all threads (with loading state)
|
||||||
|
const loadThreads = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setThreadsLoading(true);
|
||||||
|
setThreadsError(null);
|
||||||
|
|
||||||
|
const result = await getChatbotThreadsApi(request);
|
||||||
|
|
||||||
|
// Sort threads by lastActivity (newest first)
|
||||||
|
const sortedThreads = [...result.items].sort((a, b) => {
|
||||||
|
const aTime = a.lastActivity || a.startedAt || 0;
|
||||||
|
const bTime = b.lastActivity || b.startedAt || 0;
|
||||||
|
return bTime - aTime; // Descending order (newest first)
|
||||||
|
});
|
||||||
|
|
||||||
|
setThreads(sortedThreads);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error loading threads:', err);
|
||||||
|
setThreadsError(err.message || 'Failed to load threads');
|
||||||
|
} finally {
|
||||||
|
setThreadsLoading(false);
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Load threads silently (without loading state) - keeps existing threads visible
|
||||||
|
const loadThreadsSilently = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setThreadsError(null);
|
||||||
|
|
||||||
|
const result = await getChatbotThreadsApi(request);
|
||||||
|
|
||||||
|
// Sort threads by lastActivity (newest first)
|
||||||
|
const sortedThreads = [...result.items].sort((a, b) => {
|
||||||
|
const aTime = a.lastActivity || a.startedAt || 0;
|
||||||
|
const bTime = b.lastActivity || b.startedAt || 0;
|
||||||
|
return bTime - aTime; // Descending order (newest first)
|
||||||
|
});
|
||||||
|
|
||||||
|
setThreads(sortedThreads);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error loading threads silently:', err);
|
||||||
|
setThreadsError(err.message || 'Failed to load threads');
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Load a specific workflow and its messages
|
||||||
|
const loadWorkflow = useCallback(async (workflowIdToLoad: string) => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const result = await getChatbotThreadApi(request, workflowIdToLoad);
|
||||||
|
|
||||||
|
console.log('[loadWorkflow] Full result:', JSON.stringify(result, null, 2));
|
||||||
|
console.log('[loadWorkflow] chatData structure:', {
|
||||||
|
isArray: Array.isArray(result.chatData),
|
||||||
|
isObject: result.chatData && typeof result.chatData === 'object',
|
||||||
|
chatDataKeys: result.chatData && typeof result.chatData === 'object' ? Object.keys(result.chatData) : [],
|
||||||
|
hasItems: result.chatData?.items !== undefined,
|
||||||
|
itemsLength: Array.isArray(result.chatData?.items) ? result.chatData.items.length : 'not an array'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert chatData to Message[]
|
||||||
|
const loadedMessages: Message[] = [];
|
||||||
|
const processedIds = new Set<string>();
|
||||||
|
|
||||||
|
// Backend returns chatData as { items: ChatDataItem[] } structure
|
||||||
|
const chatDataArray: any[] = result.chatData?.items || [];
|
||||||
|
|
||||||
|
console.log('[loadWorkflow] Processing chatDataArray:', {
|
||||||
|
length: chatDataArray.length,
|
||||||
|
types: chatDataArray.map((item, idx) => ({
|
||||||
|
index: idx,
|
||||||
|
type: item?.type,
|
||||||
|
hasItem: !!item?.item,
|
||||||
|
keys: item ? Object.keys(item) : []
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const item of chatDataArray) {
|
||||||
|
// Handle ChatDataItem structure: { type: 'message', createdAt: number, item: Message }
|
||||||
|
if (item.type === 'message' && item.item) {
|
||||||
|
const messageData = item.item as any;
|
||||||
|
if (messageData.id && !processedIds.has(messageData.id)) {
|
||||||
|
processedIds.add(messageData.id);
|
||||||
|
loadedMessages.push(messageData as Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle direct Message structure (if item is already a message)
|
||||||
|
else if (item.role && item.message !== undefined) {
|
||||||
|
// This looks like a Message object directly
|
||||||
|
if (item.id && !processedIds.has(item.id)) {
|
||||||
|
processedIds.add(item.id);
|
||||||
|
loadedMessages.push(item as Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[loadWorkflow] Loaded messages:', {
|
||||||
|
count: loadedMessages.length,
|
||||||
|
messageIds: loadedMessages.map(m => m.id),
|
||||||
|
sampleMessage: loadedMessages.length > 0 ? loadedMessages[0] : null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort messages chronologically
|
||||||
|
const sortedMessages = loadedMessages.sort(sortMessages);
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
setMessages(sortedMessages);
|
||||||
|
setWorkflowId(workflowIdToLoad);
|
||||||
|
processedMessageIdsRef.current = processedIds;
|
||||||
|
|
||||||
|
console.log('[loadWorkflow] State updated:', {
|
||||||
|
messagesCount: sortedMessages.length,
|
||||||
|
workflowId: workflowIdToLoad
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't refresh threads list here - it causes unwanted loading state
|
||||||
|
// Threads will be refreshed after new messages are sent/completed
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error loading workflow:', err);
|
||||||
|
setError(err.message || 'Failed to load workflow');
|
||||||
|
}
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
// Select a thread (loads its messages)
|
||||||
|
const selectThread = useCallback(async (workflowIdToSelect: string) => {
|
||||||
|
setSelectedThreadId(workflowIdToSelect);
|
||||||
|
await loadWorkflow(workflowIdToSelect);
|
||||||
|
}, [loadWorkflow]);
|
||||||
|
|
||||||
|
// Auto-load threads on initialization
|
||||||
|
useEffect(() => {
|
||||||
|
loadThreads();
|
||||||
|
}, [loadThreads]);
|
||||||
|
|
||||||
|
// Handle input change
|
||||||
|
const onInputChange = useCallback((value: string) => {
|
||||||
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Abort any ongoing stream
|
||||||
|
if (streamAbortControllerRef.current) {
|
||||||
|
streamAbortControllerRef.current.abort();
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear thinking message when stopping
|
||||||
|
clearThinkingMessage();
|
||||||
|
|
||||||
|
// Call stop API
|
||||||
|
await stopChatbotApi(request, workflowId);
|
||||||
|
|
||||||
|
setIsRunning(false);
|
||||||
|
setError(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error stopping chatbot:', err);
|
||||||
|
setError(err.message || 'Failed to stop chatbot');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [workflowId, isRunning, request, clearThinkingMessage]);
|
||||||
|
|
||||||
|
// Handle form submit
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
// If running, stop instead of starting
|
||||||
|
if (isRunning && workflowId) {
|
||||||
|
await stopChatbot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedInput = inputValue.trim();
|
||||||
|
if (!trimmedInput || isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
setIsRunning(true);
|
||||||
|
|
||||||
|
// Abort any existing stream
|
||||||
|
if (streamAbortControllerRef.current) {
|
||||||
|
streamAbortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new abort controller for this stream
|
||||||
|
const abortController = new AbortController();
|
||||||
|
streamAbortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Clear thinking message when starting a new request
|
||||||
|
clearThinkingMessage();
|
||||||
|
processedLogsRef.current.clear(); // Clear processed logs for new request
|
||||||
|
|
||||||
|
// Track if this is the first event (to reset isSubmitting)
|
||||||
|
let firstEventReceived = false;
|
||||||
|
|
||||||
|
// Start SSE stream
|
||||||
|
await startChatbotStreamApi(
|
||||||
|
requestBody,
|
||||||
|
(item: ChatDataItem) => {
|
||||||
|
// Check if stream was aborted
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset isSubmitting after first event to enable stop button
|
||||||
|
if (!firstEventReceived) {
|
||||||
|
firstEventReceived = true;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the chat data item
|
||||||
|
processChatDataItem(item);
|
||||||
|
|
||||||
|
// Extract workflow ID from message if available
|
||||||
|
if (item.type === 'message' && item.item) {
|
||||||
|
const messageData = item.item as any;
|
||||||
|
if (messageData.workflowId) {
|
||||||
|
if (!workflowId) {
|
||||||
|
// New workflow created - select it automatically
|
||||||
|
setWorkflowId(messageData.workflowId);
|
||||||
|
setSelectedThreadId(messageData.workflowId);
|
||||||
|
workflowCreated = true;
|
||||||
|
} else {
|
||||||
|
// Existing workflow - ensure it's selected
|
||||||
|
setSelectedThreadId(messageData.workflowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(err: Error) => {
|
||||||
|
// Only handle error if stream wasn't aborted
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
console.error('SSE stream error:', err);
|
||||||
|
setError(err.message || 'Stream error occurred');
|
||||||
|
setIsRunning(false);
|
||||||
|
// Reset isSubmitting if stream fails before first event
|
||||||
|
if (!firstEventReceived) {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
// Clear thinking messages on error
|
||||||
|
clearThinkingMessage();
|
||||||
|
} else {
|
||||||
|
// Stream was aborted (stopped) - clear thinking messages
|
||||||
|
clearThinkingMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Stream completed
|
||||||
|
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 (final message should have cleared it, but ensure cleanup)
|
||||||
|
clearThinkingMessage();
|
||||||
|
// Refresh threads list after message completion (silently, without loading state)
|
||||||
|
setTimeout(() => {
|
||||||
|
loadThreadsSilently();
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
// Stream was aborted (stopped) - clear thinking messages
|
||||||
|
clearThinkingMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear input after starting (optimistic)
|
||||||
|
if (!workflowId) {
|
||||||
|
setInputValue('');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error starting chatbot:', err);
|
||||||
|
setError(err.message || 'Failed to start chatbot');
|
||||||
|
setIsRunning(false);
|
||||||
|
// Clear thinking messages on error
|
||||||
|
clearThinkingMessage();
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]);
|
||||||
|
|
||||||
|
// Delete a chatbot workflow
|
||||||
|
const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Add to deleting set
|
||||||
|
setDeletingThreads(prev => new Set(prev).add(workflowIdToDelete));
|
||||||
|
|
||||||
|
// If deleting the selected thread, clear selection and messages
|
||||||
|
if (selectedThreadId === workflowIdToDelete) {
|
||||||
|
setMessages([]);
|
||||||
|
setWorkflowId(null);
|
||||||
|
setSelectedThreadId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call delete API
|
||||||
|
await deleteChatbotWorkflowApi(request, workflowIdToDelete);
|
||||||
|
|
||||||
|
// Remove from threads list optimistically
|
||||||
|
setThreads(prev => prev.filter(t => t.id !== workflowIdToDelete));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error deleting thread:', err);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
// Remove from deleting set
|
||||||
|
setDeletingThreads(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(workflowIdToDelete);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [request, selectedThreadId]);
|
||||||
|
|
||||||
|
// Optimistically remove thread from list
|
||||||
|
const removeThreadOptimistically = useCallback((workflowId: string) => {
|
||||||
|
setThreads(prev => prev.filter(t => t.id !== workflowId));
|
||||||
|
// If deleting the selected thread, clear selection
|
||||||
|
if (selectedThreadId === workflowId) {
|
||||||
|
setSelectedThreadId(null);
|
||||||
|
setMessages([]);
|
||||||
|
setWorkflowId(null);
|
||||||
|
}
|
||||||
|
}, [selectedThreadId]);
|
||||||
|
|
||||||
|
// Start a new chat (clears selection and messages)
|
||||||
|
const startNewChat = useCallback(() => {
|
||||||
|
// Abort any ongoing stream
|
||||||
|
if (streamAbortControllerRef.current) {
|
||||||
|
streamAbortControllerRef.current.abort();
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear messages and selection
|
||||||
|
setMessages([]);
|
||||||
|
setWorkflowId(null);
|
||||||
|
setSelectedThreadId(null);
|
||||||
|
setError(null);
|
||||||
|
setInputValue('');
|
||||||
|
setPendingFileIds([]);
|
||||||
|
pendingFileIdsRef.current = [];
|
||||||
|
setUploadedFiles([]);
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
processedLogsRef.current.clear();
|
||||||
|
clearProcessedMessages();
|
||||||
|
}, [clearProcessedMessages]);
|
||||||
|
|
||||||
|
// Reset chatbot state
|
||||||
|
const resetChatbot = useCallback(() => {
|
||||||
|
// Abort any ongoing stream
|
||||||
|
if (streamAbortControllerRef.current) {
|
||||||
|
streamAbortControllerRef.current.abort();
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages([]);
|
||||||
|
setWorkflowId(null);
|
||||||
|
setSelectedThreadId(null);
|
||||||
|
setIsRunning(false);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setError(null);
|
||||||
|
setInputValue('');
|
||||||
|
setPendingFileIds([]);
|
||||||
|
pendingFileIdsRef.current = [];
|
||||||
|
setUploadedFiles([]);
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
processedLogsRef.current.clear();
|
||||||
|
clearProcessedMessages();
|
||||||
|
}, [clearProcessedMessages]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (streamAbortControllerRef.current) {
|
||||||
|
streamAbortControllerRef.current.abort();
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
logQueueRef.current = [];
|
||||||
|
isProcessingLogsRef.current = false;
|
||||||
|
processedLogsRef.current.clear();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoized display messages
|
||||||
|
const displayMessages = useMemo(() => {
|
||||||
|
return messages.sort(sortMessages);
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// GenericDataHook interface
|
||||||
|
data: [],
|
||||||
|
loading: false,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Input form interface
|
||||||
|
inputValue,
|
||||||
|
onInputChange,
|
||||||
|
handleSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
|
||||||
|
// Workflow state
|
||||||
|
workflowId: workflowId || undefined,
|
||||||
|
isRunning,
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
messages: displayMessages,
|
||||||
|
|
||||||
|
// Chat history state
|
||||||
|
threads,
|
||||||
|
selectedThreadId,
|
||||||
|
threadsLoading,
|
||||||
|
threadsError,
|
||||||
|
|
||||||
|
// Chat history methods
|
||||||
|
selectThread,
|
||||||
|
loadThreads,
|
||||||
|
|
||||||
|
// Delete methods
|
||||||
|
handleDelete: handleDeleteThread,
|
||||||
|
removeOptimistically: removeThreadOptimistically,
|
||||||
|
deletingItems: deletingThreads,
|
||||||
|
refetch: loadThreads,
|
||||||
|
|
||||||
|
// Additional methods
|
||||||
|
stopChatbot,
|
||||||
|
resetChatbot,
|
||||||
|
startNewChat,
|
||||||
|
cleanup,
|
||||||
|
|
||||||
|
// File upload interface
|
||||||
|
handleFileUpload,
|
||||||
|
handleUpload: handleFileUpload, // Alias for compatibility with DragDropOverlay
|
||||||
|
handleFileRemove,
|
||||||
|
pendingFileIds,
|
||||||
|
uploadedFiles,
|
||||||
|
uploadingFile,
|
||||||
|
uploadError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatbotHook() {
|
||||||
|
return () => useChatbot();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,177 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chatbot two-column layout */
|
||||||
|
.chatbotTwoColumnLayout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 3fr;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
height: calc(100vh);
|
||||||
|
max-height: calc(100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotHistoryColumn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotChatColumn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotMessagesCell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotInputCell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force chat history list styling */
|
||||||
|
.chatHistorySection :global(.listContainer) {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem) {
|
||||||
|
border: none !important;
|
||||||
|
border-top: 1px solid var(--color-medium-gray) !important;
|
||||||
|
border-bottom: 1px solid var(--color-medium-gray) !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0.875rem 0.75rem !important;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem:first-child) {
|
||||||
|
border-top: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem:hover) {
|
||||||
|
border-color: var(--color-medium-gray) !important;
|
||||||
|
border-top-color: var(--color-medium-gray) !important;
|
||||||
|
border-bottom-color: var(--color-medium-gray) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem[data-selected-thread-id="true"]) {
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
padding-left: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-medium-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryCount {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
color: var(--color-gray);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton:hover {
|
||||||
|
background: var(--color-highlight-gray);
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton:active {
|
||||||
|
background: var(--color-medium-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryEmpty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryError {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--color-red);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.scrollableContent {
|
.scrollableContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue