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-icons": "^5.5.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-markdown": "^9.1.0",
|
||||
"react-router-dom": "^7.7.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"xstate": "^5.20.1"
|
||||
},
|
||||
"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 {
|
||||
background: var(--color-secondary-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.actionButton:disabled {
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ export function FormGeneratorControls({
|
|||
filterFocused,
|
||||
onFilterFocus,
|
||||
selectedCount,
|
||||
displayData: _displayData,
|
||||
displayData,
|
||||
onDeleteSingle,
|
||||
onDeleteMultiple,
|
||||
onRefresh,
|
||||
|
|
@ -76,6 +76,9 @@ export function FormGeneratorControls({
|
|||
}: FormGeneratorControlsProps) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
// Check if all items are selected
|
||||
const allItemsSelected = selectedCount > 0 && displayData.length > 0 && selectedCount === displayData.length;
|
||||
|
||||
// Filter fields that are filterable
|
||||
const filterableFields = fields.filter(field => {
|
||||
if (field.type === 'readonly') return false;
|
||||
|
|
@ -159,7 +162,8 @@ export function FormGeneratorControls({
|
|||
{/* Delete Controls - Show when items are selected */}
|
||||
{selectable && selectedCount > 0 && (
|
||||
<div className={styles.deleteControlsIntegrated}>
|
||||
{selectedCount === 1 && onDeleteSingle && (
|
||||
{/* Show delete single only if exactly 1 item selected AND not all items */}
|
||||
{selectedCount === 1 && !allItemsSelected && onDeleteSingle && (
|
||||
<Button
|
||||
onClick={onDeleteSingle}
|
||||
variant="primary"
|
||||
|
|
@ -169,14 +173,17 @@ export function FormGeneratorControls({
|
|||
{t('formgen.delete.single', 'Delete')}
|
||||
</Button>
|
||||
)}
|
||||
{selectedCount > 1 && onDeleteMultiple && (
|
||||
{/* Show delete multiple if more than 1 selected OR all items are selected */}
|
||||
{(selectedCount > 1 || allItemsSelected) && onDeleteMultiple && (
|
||||
<Button
|
||||
onClick={onDeleteMultiple}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={FaTrash}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,26 @@
|
|||
.formGeneratorList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
font-family: var(--font-family);
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* List Container */
|
||||
.listContainer {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 25px;
|
||||
background: var(--color-bg);
|
||||
max-height: calc(100vh - 400px);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
padding-right: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.emptyList {
|
||||
|
|
@ -60,13 +65,15 @@
|
|||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--color-primary);
|
||||
background: var(--color-bg);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-medium-gray);
|
||||
background: transparent;
|
||||
position: static;
|
||||
padding: 0;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.selectAllCheckbox {
|
||||
|
|
@ -80,6 +87,7 @@
|
|||
border: 2px solid var(--color-primary);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selectAllCheckbox:checked {
|
||||
|
|
@ -87,6 +95,86 @@
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
|
@ -128,29 +216,44 @@
|
|||
.itemsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 20px;
|
||||
background: var(--color-bg);
|
||||
transition: all 0.2s ease;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
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 {
|
||||
border-color: var(--color-secondary);
|
||||
box-shadow: 0 2px 8px rgba(var(--color-secondary-rgb), 0.1);
|
||||
background: var(--color-highlight-gray);
|
||||
border-color: var(--color-medium-gray);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.listItem.selected {
|
||||
background: rgba(var(--color-secondary-rgb), 0.1);
|
||||
border-color: var(--color-secondary);
|
||||
background: var(--color-highlight-gray);
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.listItem.clickable {
|
||||
|
|
@ -159,7 +262,7 @@
|
|||
|
||||
.itemSelect {
|
||||
flex-shrink: 0;
|
||||
padding-top: 4px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.itemCheckbox {
|
||||
|
|
@ -189,40 +292,126 @@
|
|||
display: flex;
|
||||
gap: 4px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.metadataFields {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
margin-top: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.itemField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
.itemField:first-child {
|
||||
gap: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.itemField:first-child .fieldValue {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-family);
|
||||
padding: 8px 12px;
|
||||
background: var(--color-bg-disabled, rgba(0, 0, 0, 0.02));
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.fieldInput {
|
||||
|
|
@ -401,21 +590,29 @@
|
|||
|
||||
/* Custom scrollbar for list container */
|
||||
.listContainer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-track {
|
||||
background: var(--color-gray-disabled);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.listContainer::-webkit-scrollbar-thumb {
|
||||
background: var(--color-gray);
|
||||
border-radius: 4px;
|
||||
background: var(--color-medium-gray);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.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 { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from './FormGeneratorList.module.css';
|
||||
import actionButtonStyles from '../ActionButtons/ActionButton.module.css';
|
||||
import {
|
||||
EditActionButton,
|
||||
DeleteActionButton,
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
import TextField from '../../UiComponents/TextField/TextField';
|
||||
import { FormGeneratorControls } from '../FormGeneratorControls';
|
||||
import { IoIosTrash, IoIosCheckmark, IoIosClose } from "react-icons/io";
|
||||
import {
|
||||
isSelectType,
|
||||
isCheckboxType,
|
||||
|
|
@ -49,6 +51,7 @@ export interface FormGeneratorListProps<T = any> {
|
|||
selectable?: boolean;
|
||||
isItemSelectable?: (row: T) => boolean;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
actionButtons?: {
|
||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
||||
onAction?: (row: T) => Promise<void> | void;
|
||||
|
|
@ -75,6 +78,7 @@ export interface FormGeneratorListProps<T = any> {
|
|||
getItemDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||
hookData?: any;
|
||||
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>>({
|
||||
|
|
@ -92,6 +96,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
selectable = true,
|
||||
isItemSelectable,
|
||||
loading = false,
|
||||
emptyMessage,
|
||||
actionButtons = [],
|
||||
onDelete,
|
||||
onDeleteMultiple,
|
||||
|
|
@ -99,7 +104,9 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
className = '',
|
||||
getItemDataAttributes,
|
||||
hookData,
|
||||
onFieldChange
|
||||
onFieldChange,
|
||||
title,
|
||||
headerButton
|
||||
}: FormGeneratorListProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
|
||||
|
|
@ -180,6 +187,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Check if backend pagination is supported
|
||||
const supportsBackendPagination = hookData?.refetch && typeof hookData.refetch === 'function';
|
||||
|
|
@ -238,6 +247,12 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
|
||||
// Data is already filtered, sorted, and paginated by the backend
|
||||
const displayData = data;
|
||||
|
||||
// Check if all items are selected
|
||||
const allItemsSelected = selectedItems.size > 0 && displayData.length > 0 && selectedItems.size === displayData.length;
|
||||
|
||||
// Check if any items are selected
|
||||
const hasSelectedItems = selectedItems.size > 0;
|
||||
|
||||
// Get pagination info from backend
|
||||
const totalPages = useMemo(() => {
|
||||
|
|
@ -319,38 +334,164 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
}
|
||||
};
|
||||
|
||||
// Handle delete single item
|
||||
const handleDeleteSingle = (row: T, index: number) => {
|
||||
if (onDelete) {
|
||||
onDelete(row);
|
||||
if (selectedItems.has(index)) {
|
||||
const newSelected = new Set(selectedItems);
|
||||
newSelected.delete(index);
|
||||
setSelectedItems(newSelected);
|
||||
if (onItemSelect) {
|
||||
const selectedData = Array.from(newSelected).map(i => displayData[i]);
|
||||
onItemSelect(selectedData);
|
||||
// Handle delete multiple items click (show confirmation)
|
||||
const handleDeleteMultipleClick = () => {
|
||||
if (selectedItems.size === 0 || isDeleting) return;
|
||||
setIsConfirmingDelete(true);
|
||||
};
|
||||
|
||||
// Handle confirm delete
|
||||
const handleConfirmDelete = async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
setIsConfirmingDelete(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setIsConfirmingDelete(false);
|
||||
|
||||
try {
|
||||
const selectedData = Array.from(selectedItems).map(i => displayData[i]);
|
||||
console.log('Deleting items:', selectedData.length, 'items', selectedData);
|
||||
|
||||
// Try to use hookData first (like DeleteActionButton does)
|
||||
if (hookData) {
|
||||
const handleDelete = hookData.handleDelete || hookData.handleDeleteMultiple;
|
||||
const removeOptimistically = hookData.removeOptimistically || hookData.removeFileOptimistically;
|
||||
const refetch = hookData.refetch;
|
||||
const idField = 'id'; // Default ID field, could be made configurable
|
||||
|
||||
if (handleDelete) {
|
||||
console.log('Using hookData.handleDelete');
|
||||
|
||||
// Get IDs from selected items
|
||||
const selectedIds = selectedData.map(row => (row as any)[idField]).filter(Boolean);
|
||||
console.log('Selected IDs:', selectedIds);
|
||||
|
||||
// Optimistically remove items from UI
|
||||
if (removeOptimistically) {
|
||||
selectedIds.forEach(id => removeOptimistically(id));
|
||||
}
|
||||
|
||||
// Delete each item
|
||||
const deletePromises = selectedIds.map(id => handleDelete(id));
|
||||
const results = await Promise.all(deletePromises);
|
||||
|
||||
// Check if all deletions succeeded
|
||||
const allSucceeded = results.every(result => result !== false);
|
||||
|
||||
if (allSucceeded) {
|
||||
// If we used optimistic removal, don't refetch immediately
|
||||
if (!removeOptimistically && refetch) {
|
||||
await refetch();
|
||||
}
|
||||
} else {
|
||||
// Some deletions failed, refetch to restore state
|
||||
if (refetch) {
|
||||
await refetch();
|
||||
}
|
||||
throw new Error('Some items failed to delete');
|
||||
}
|
||||
} else if (onDeleteMultiple) {
|
||||
console.log('Using onDeleteMultiple prop');
|
||||
const result = onDeleteMultiple(selectedData) as any;
|
||||
if (result && typeof result.then === 'function') {
|
||||
await result;
|
||||
}
|
||||
} else if (onDelete) {
|
||||
console.log('Using onDelete prop for each item');
|
||||
for (const row of selectedData) {
|
||||
const result = onDelete(row) as any;
|
||||
if (result && typeof result.then === 'function') {
|
||||
await result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('No delete handler found in hookData or props');
|
||||
alert('No delete handler configured');
|
||||
return;
|
||||
}
|
||||
} else if (onDeleteMultiple) {
|
||||
console.log('Using onDeleteMultiple prop (no hookData)');
|
||||
const result = onDeleteMultiple(selectedData) as any;
|
||||
if (result && typeof result.then === 'function') {
|
||||
await result;
|
||||
}
|
||||
} else if (onDelete) {
|
||||
console.log('Using onDelete prop for each item (no hookData)');
|
||||
for (const row of selectedData) {
|
||||
const result = onDelete(row) as any;
|
||||
if (result && typeof result.then === 'function') {
|
||||
await result;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('No delete handler provided');
|
||||
alert('No delete handler configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear selection after deletion
|
||||
setSelectedItems(new Set());
|
||||
onItemSelect?.([]);
|
||||
console.log('Delete completed, selection cleared');
|
||||
} catch (error) {
|
||||
console.error('Delete failed:', error);
|
||||
alert(`Delete failed: ${error}`);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete multiple items
|
||||
const handleDeleteMultiple = () => {
|
||||
if (onDeleteMultiple && selectedItems.size > 0) {
|
||||
const selectedData = Array.from(selectedItems).map(i => displayData[i]);
|
||||
onDeleteMultiple(selectedData);
|
||||
setSelectedItems(new Set());
|
||||
onItemSelect?.([]);
|
||||
}
|
||||
// Handle cancel delete
|
||||
const handleCancelDelete = () => {
|
||||
setIsConfirmingDelete(false);
|
||||
};
|
||||
|
||||
// Handle clicks outside delete confirmation buttons
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isConfirmingDelete) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(`.${styles.headerDeleteButtonContainer}`)) {
|
||||
setIsConfirmingDelete(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isConfirmingDelete) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isConfirmingDelete]);
|
||||
|
||||
// Handle page size change
|
||||
const handlePageSizeChange = (newPageSize: number) => {
|
||||
setCurrentPageSize(newPageSize);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Get status badge class based on status value
|
||||
const getStatusBadgeClass = (value: any): string => {
|
||||
const statusValue = String(value || '').toLowerCase().trim();
|
||||
|
||||
if (statusValue === 'completed' || statusValue === 'success' || statusValue === 'done') {
|
||||
return styles.completed;
|
||||
}
|
||||
if (statusValue === 'pending' || statusValue === 'waiting' || statusValue === 'in_progress' || statusValue === 'in progress') {
|
||||
return styles.pending;
|
||||
}
|
||||
if (statusValue === 'failed' || statusValue === 'error' || statusValue === 'cancelled' || statusValue === 'canceled') {
|
||||
return styles.failed;
|
||||
}
|
||||
if (statusValue === 'active' || statusValue === 'running') {
|
||||
return styles.active;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Format field value
|
||||
const formatFieldValue = (value: any, field: FieldConfig, row: T) => {
|
||||
if (field.formatter) {
|
||||
|
|
@ -454,10 +595,27 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
|
||||
// Render field input
|
||||
const renderFieldInput = (field: FieldConfig, value: any, row: T, _index: number) => {
|
||||
const isStatusField = field.key.toLowerCase().includes('status');
|
||||
|
||||
if (field.type === 'readonly' || !field.editable) {
|
||||
const formattedValue = formatFieldValue(value, field, row);
|
||||
|
||||
// Apply status badge styling for status fields
|
||||
if (isStatusField) {
|
||||
const statusClass = getStatusBadgeClass(value);
|
||||
|
||||
return (
|
||||
<div className={styles.fieldValue} key={field.key}>
|
||||
<span className={`${styles.statusBadge} ${statusClass}`}>
|
||||
{formattedValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.fieldValue} key={field.key}>
|
||||
{formatFieldValue(value, field, row)}
|
||||
{formattedValue}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -510,7 +668,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
return (
|
||||
<div className={`${styles.formGeneratorList} ${className}`}>
|
||||
|
||||
{(searchable || filterable || (selectable && selectedItems.size > 0)) && (
|
||||
{(searchable || filterable) && selectedItems.size === 0 && (
|
||||
<FormGeneratorControls
|
||||
fields={detectedFields}
|
||||
searchTerm={searchTerm}
|
||||
|
|
@ -523,12 +681,8 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
onFilterFocus={handleFilterFocus}
|
||||
selectedCount={selectedItems.size}
|
||||
displayData={displayData}
|
||||
onDeleteSingle={selectedItems.size === 1 && onDelete ? () => {
|
||||
const selectedIndex = Array.from(selectedItems)[0];
|
||||
const selectedRow = displayData[selectedIndex];
|
||||
handleDeleteSingle(selectedRow, selectedIndex);
|
||||
} : undefined}
|
||||
onDeleteMultiple={selectedItems.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
||||
onDeleteSingle={undefined}
|
||||
onDeleteMultiple={undefined}
|
||||
onRefresh={onRefresh}
|
||||
searchable={searchable}
|
||||
filterable={filterable}
|
||||
|
|
@ -562,7 +716,59 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
title={t('formgen.select.all', 'Select all items')}
|
||||
className={styles.selectAllCheckbox}
|
||||
/>
|
||||
{sortable && (
|
||||
{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 && (
|
||||
<div className={styles.sortControls}>
|
||||
{detectedFields.map(field => (
|
||||
<button
|
||||
|
|
@ -586,7 +792,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
{/* List Items */}
|
||||
{displayData.length === 0 ? (
|
||||
<div className={styles.emptyMessage}>
|
||||
{t('formgen.empty', 'No data available')}
|
||||
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.itemsList}>
|
||||
|
|
@ -697,17 +903,61 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
|
||||
{/* Fields */}
|
||||
<div className={styles.itemFields}>
|
||||
{detectedFields.map(field => {
|
||||
{detectedFields.map((field, fieldIndex) => {
|
||||
const cellValue = row[field.key];
|
||||
const customClassName = field.cellClassName ? field.cellClassName(cellValue, row) : '';
|
||||
|
||||
// First field (name) - render normally
|
||||
if (fieldIndex === 0) {
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`${styles.itemField} ${customClassName}`}
|
||||
>
|
||||
<label className={styles.fieldLabel}>{field.label}</label>
|
||||
{renderFieldInput(field, cellValue, row, fieldIndex)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Second and third fields (date and status) - wrap in container
|
||||
if (fieldIndex === 1) {
|
||||
const nextField = detectedFields[2];
|
||||
const nextFieldValue = nextField ? row[nextField.key] : null;
|
||||
|
||||
return (
|
||||
<div key={`metadata-${field.key}`} className={styles.metadataFields}>
|
||||
<div
|
||||
className={`${styles.itemField} ${customClassName}`}
|
||||
>
|
||||
<label className={styles.fieldLabel}>{field.label}</label>
|
||||
{renderFieldInput(field, cellValue, row, fieldIndex)}
|
||||
</div>
|
||||
{nextField && (
|
||||
<div
|
||||
className={`${styles.itemField} ${nextField.cellClassName ? nextField.cellClassName(nextFieldValue, row) : ''}`}
|
||||
>
|
||||
<label className={styles.fieldLabel}>{nextField.label}</label>
|
||||
{renderFieldInput(nextField, nextFieldValue, row, 2)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skip third field if it was already rendered with second field
|
||||
if (fieldIndex === 2 && detectedFields.length > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Any additional fields beyond the first three
|
||||
return (
|
||||
<div
|
||||
key={field.key}
|
||||
className={`${styles.itemField} ${customClassName}`}
|
||||
>
|
||||
<label className={styles.fieldLabel}>{field.label}</label>
|
||||
{renderFieldInput(field, cellValue, row, index)}
|
||||
{renderFieldInput(field, cellValue, row, fieldIndex)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Message } from '../MessagesTypes';
|
||||
import { formatTimestamp } from '../MessageUtils';
|
||||
import { DocumentItem, ActionInfo } from '../MessageParts';
|
||||
|
|
@ -47,7 +49,62 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||
{/* Message content */}
|
||||
{message.message && (
|
||||
<div className={styles.messageContent}>
|
||||
{message.message}
|
||||
<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}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@
|
|||
.messageContent {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +173,322 @@
|
|||
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 {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { GenericPageData, PageButton, PageContent, resolveLanguageText, Settings
|
|||
import { FormGenerator } from '../../components/FormGenerator';
|
||||
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
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 { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
||||
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
||||
|
|
@ -200,11 +203,57 @@ const ContentRenderer: React.FC<{
|
|||
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 hasLog = visibleContents.some(c => c.type === 'log');
|
||||
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) {
|
||||
// Render dashboard grid layout
|
||||
|
|
@ -957,7 +1006,12 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
|||
const buttonVariant = isRunning
|
||||
? (config.stopButtonVariant || 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
|
||||
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);
|
||||
|
||||
// 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
|
||||
// Show prompt selector if user has permission to view/read prompts (even if no prompts exist yet)
|
||||
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)
|
||||
return (
|
||||
<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:
|
||||
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 (hookData?.handleUpload) {
|
||||
// If the page has drag drop config and hook data with handleUpload or handleFileUpload, integrate them
|
||||
const uploadHandler = hookData?.handleUpload || hookData?.handleFileUpload;
|
||||
if (uploadHandler) {
|
||||
return {
|
||||
...pageData.dragDropConfig,
|
||||
onDrop: async (files: File[]) => {
|
||||
|
||||
try {
|
||||
// Process each file through the hook's handleUpload function
|
||||
// Process each file through the hook's upload function
|
||||
for (const file of files) {
|
||||
if (hookData.handleUpload) {
|
||||
|
||||
await hookData.handleUpload(file);
|
||||
|
||||
if (uploadHandler) {
|
||||
await uploadHandler(file);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
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 { pekPageData } from './pek';
|
||||
export { pekTablesPageData } from './pek-tables';
|
||||
export { chatbotPageData } from './chatbot';
|
||||
|
||||
// Import all page data
|
||||
import { dashboardPageData } from './dashboard';
|
||||
|
|
@ -21,6 +22,7 @@ import { speechPageData } from './speech';
|
|||
import { settingsPageData } from './settings';
|
||||
import { pekPageData } from './pek';
|
||||
import { pekTablesPageData } from './pek-tables';
|
||||
import { chatbotPageData } from './chatbot';
|
||||
|
||||
// Array of all page data
|
||||
export const allPageData = [
|
||||
|
|
@ -34,6 +36,7 @@ export const allPageData = [
|
|||
settingsPageData,
|
||||
pekPageData,
|
||||
pekTablesPageData,
|
||||
chatbotPageData,
|
||||
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export interface InputFormConfig {
|
|||
stopButtonVariant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning'; // Variant for stop button
|
||||
buttonSize?: '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
|
||||
|
|
@ -123,7 +124,7 @@ export interface SettingsConfig {
|
|||
// Content section for paragraphs
|
||||
export interface PageContent {
|
||||
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
|
||||
level?: number; // For headings (1-6)
|
||||
items?: (string | LanguageText)[]; // For lists
|
||||
|
|
@ -148,6 +149,10 @@ export interface PageContent {
|
|||
logConfig?: {
|
||||
emptyMessage?: string | LanguageText;
|
||||
};
|
||||
// Chat history-specific properties
|
||||
chatHistoryConfig?: {
|
||||
emptyMessage?: string | LanguageText;
|
||||
};
|
||||
}
|
||||
|
||||
// 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) {
|
||||
background: var(--button-primary-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.buttonSecondary {
|
||||
|
|
@ -105,8 +104,8 @@
|
|||
}
|
||||
|
||||
.buttonSecondary:hover:not(:disabled) {
|
||||
background: var(--button-secondary-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
background: var(--color-secondary-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.buttonDanger {
|
||||
|
|
@ -116,7 +115,6 @@
|
|||
|
||||
.buttonDanger:hover:not(:disabled) {
|
||||
background: var(--button-danger-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.buttonSuccess {
|
||||
|
|
@ -126,7 +124,6 @@
|
|||
|
||||
.buttonSuccess:hover:not(:disabled) {
|
||||
background: var(--button-success-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.buttonWarning {
|
||||
|
|
@ -136,7 +133,6 @@
|
|||
|
||||
.buttonWarning:hover:not(:disabled) {
|
||||
background: var(--button-warning-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
|
|
|||
|
|
@ -311,6 +311,177 @@
|
|||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Reference in a new issue