feat: completely build up althaus page
This commit is contained in:
parent
ae6a634274
commit
c76e7efd28
14 changed files with 3419 additions and 66 deletions
1473
package-lock.json
generated
1473
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -35,7 +35,9 @@
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"react-markdown": "^9.1.0",
|
||||||
"react-router-dom": "^7.7.1",
|
"react-router-dom": "^7.7.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"xstate": "^5.20.1"
|
"xstate": "^5.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
310
src/api/chatbotApi.ts
Normal file
310
src/api/chatbotApi.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
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
|
||||||
|
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 })
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -176,7 +176,9 @@ export function FormGeneratorControls({
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={FaTrash}
|
icon={FaTrash}
|
||||||
>
|
>
|
||||||
{t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
{selectedCount === displayData.length && displayData.length > 0
|
||||||
|
? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString())
|
||||||
|
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
.formGeneratorList {
|
.formGeneratorList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* List Container */
|
/* List Container */
|
||||||
.listContainer {
|
.listContainer {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border: 1px solid var(--color-primary);
|
border: none;
|
||||||
border-radius: 25px;
|
border-radius: 0;
|
||||||
background: var(--color-bg);
|
background: transparent;
|
||||||
max-height: calc(100vh - 400px);
|
max-height: none;
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyList {
|
.emptyList {
|
||||||
|
|
@ -60,13 +64,15 @@
|
||||||
.listHeader {
|
.listHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 15px;
|
gap: 0.5rem;
|
||||||
padding: 12px 16px;
|
margin-bottom: 1rem;
|
||||||
border-bottom: 1px solid var(--color-primary);
|
padding-bottom: 0.75rem;
|
||||||
background: var(--color-bg);
|
border-bottom: 1px solid var(--color-medium-gray);
|
||||||
position: sticky;
|
background: transparent;
|
||||||
top: 0;
|
position: static;
|
||||||
z-index: 10;
|
padding: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectAllCheckbox {
|
.selectAllCheckbox {
|
||||||
|
|
@ -80,6 +86,7 @@
|
||||||
border: 2px solid var(--color-primary);
|
border: 2px solid var(--color-primary);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectAllCheckbox:checked {
|
.selectAllCheckbox:checked {
|
||||||
|
|
@ -87,6 +94,27 @@
|
||||||
border-color: var(--color-secondary);
|
border-color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.listTitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerButtonWrapper {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listCount {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.sortControls {
|
.sortControls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|
@ -128,29 +156,44 @@
|
||||||
.itemsList {
|
.itemsList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
padding: 16px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem {
|
.listItem {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
flex-direction: row;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
padding: 16px;
|
justify-content: space-between;
|
||||||
border: 1px solid var(--color-primary);
|
padding: 0.875rem 0.75rem;
|
||||||
border-radius: 20px;
|
background: transparent;
|
||||||
background: var(--color-bg);
|
border: none;
|
||||||
transition: all 0.2s ease;
|
border-top: 1px solid var(--color-medium-gray);
|
||||||
|
border-bottom: 1px solid var(--color-medium-gray);
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.5rem;
|
||||||
|
animation: slideInFromTop 0.3s ease-out;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem:first-child {
|
||||||
|
border-top: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem:hover {
|
.listItem:hover {
|
||||||
border-color: var(--color-secondary);
|
background: var(--color-highlight-gray);
|
||||||
box-shadow: 0 2px 8px rgba(var(--color-secondary-rgb), 0.1);
|
border-color: var(--color-medium-gray);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem.selected {
|
.listItem.selected {
|
||||||
background: rgba(var(--color-secondary-rgb), 0.1);
|
background: var(--color-highlight-gray);
|
||||||
border-color: var(--color-secondary);
|
border-left: none;
|
||||||
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem.clickable {
|
.listItem.clickable {
|
||||||
|
|
@ -159,7 +202,7 @@
|
||||||
|
|
||||||
.itemSelect {
|
.itemSelect {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 4px;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemCheckbox {
|
.itemCheckbox {
|
||||||
|
|
@ -189,13 +232,20 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 4px;
|
padding-top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.listItem:hover .itemActions,
|
||||||
|
.listItem.selected .itemActions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.itemFields {
|
.itemFields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 0.375rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
@ -203,26 +253,52 @@
|
||||||
.itemField {
|
.itemField {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.itemField:first-child {
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemField:nth-child(2),
|
||||||
|
.itemField:nth-child(3) {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemField:nth-child(2) {
|
||||||
|
margin-right: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemField:nth-child(3) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemField:nth-child(2) .fieldValue,
|
||||||
|
.itemField:nth-child(3) .fieldValue {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.fieldLabel {
|
.fieldLabel {
|
||||||
font-size: 12px;
|
display: none;
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
opacity: 0.7;
|
|
||||||
font-family: var(--font-family);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldValue {
|
.fieldValue {
|
||||||
font-size: 14px;
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-family: var(--font-family);
|
overflow: hidden;
|
||||||
padding: 8px 12px;
|
text-overflow: ellipsis;
|
||||||
background: var(--color-bg-disabled, rgba(0, 0, 0, 0.02));
|
white-space: nowrap;
|
||||||
border-radius: 12px;
|
line-height: 1.4;
|
||||||
border: 1px solid transparent;
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldInput {
|
.fieldInput {
|
||||||
|
|
@ -401,21 +477,31 @@
|
||||||
|
|
||||||
/* Custom scrollbar for list container */
|
/* Custom scrollbar for list container */
|
||||||
.listContainer::-webkit-scrollbar {
|
.listContainer::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 4px;
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.listContainer::-webkit-scrollbar-track {
|
.listContainer::-webkit-scrollbar-track {
|
||||||
background: var(--color-gray-disabled);
|
background: transparent;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.listContainer::-webkit-scrollbar-thumb {
|
.listContainer::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-gray);
|
background: var(--color-medium-gray);
|
||||||
border-radius: 4px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listContainer::-webkit-scrollbar-thumb:hover {
|
.listContainer::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-secondary);
|
background: var(--color-gray-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromTop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export interface FormGeneratorListProps<T = any> {
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
isItemSelectable?: (row: T) => boolean;
|
isItemSelectable?: (row: T) => boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
actionButtons?: {
|
actionButtons?: {
|
||||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
||||||
onAction?: (row: T) => Promise<void> | void;
|
onAction?: (row: T) => Promise<void> | void;
|
||||||
|
|
@ -75,6 +76,7 @@ export interface FormGeneratorListProps<T = any> {
|
||||||
getItemDataAttributes?: (row: T, index: number) => Record<string, string>;
|
getItemDataAttributes?: (row: T, index: number) => Record<string, string>;
|
||||||
hookData?: any;
|
hookData?: any;
|
||||||
onFieldChange?: (row: T, fieldKey: string, value: any) => void; // For editable fields
|
onFieldChange?: (row: T, fieldKey: string, value: any) => void; // For editable fields
|
||||||
|
headerButton?: React.ReactNode; // Custom button in header
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FormGeneratorList<T extends Record<string, any>>({
|
export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
|
|
@ -92,6 +94,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
selectable = true,
|
selectable = true,
|
||||||
isItemSelectable,
|
isItemSelectable,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
emptyMessage,
|
||||||
actionButtons = [],
|
actionButtons = [],
|
||||||
onDelete,
|
onDelete,
|
||||||
onDeleteMultiple,
|
onDeleteMultiple,
|
||||||
|
|
@ -99,7 +102,9 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
className = '',
|
className = '',
|
||||||
getItemDataAttributes,
|
getItemDataAttributes,
|
||||||
hookData,
|
hookData,
|
||||||
onFieldChange
|
onFieldChange,
|
||||||
|
title,
|
||||||
|
headerButton
|
||||||
}: FormGeneratorListProps<T>) {
|
}: FormGeneratorListProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
|
@ -453,7 +458,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render field input
|
// Render field input
|
||||||
const renderFieldInput = (field: FieldConfig, value: any, row: T, index: number) => {
|
const renderFieldInput = (field: FieldConfig, value: any, row: T) => {
|
||||||
if (field.type === 'readonly' || !field.editable) {
|
if (field.type === 'readonly' || !field.editable) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.fieldValue} key={field.key}>
|
<div className={styles.fieldValue} key={field.key}>
|
||||||
|
|
@ -490,13 +495,30 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to text input
|
// Handle textarea separately
|
||||||
|
if (field.type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
key={field.key}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onFieldChange?.(row, field.key, e.target.value)}
|
||||||
|
className={styles.fieldInput}
|
||||||
|
required={field.required}
|
||||||
|
readOnly={!field.editable}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to text input - map to valid TextField types
|
||||||
|
const inputType = attributeTypeToInputType(field.type || 'string');
|
||||||
|
const textFieldType = inputType === 'textarea' ? 'text' : (inputType === 'datetime-local' ? 'text' : inputType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextField
|
<TextField
|
||||||
key={field.key}
|
key={field.key}
|
||||||
value={value || ''}
|
value={value || ''}
|
||||||
onChange={(newValue) => onFieldChange?.(row, field.key, newValue)}
|
onChange={(newValue) => onFieldChange?.(row, field.key, newValue)}
|
||||||
type={attributeTypeToInputType(field.type || 'string')}
|
type={textFieldType as 'text' | 'number' | 'email' | 'url' | 'password'}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
readonly={!field.editable}
|
readonly={!field.editable}
|
||||||
className={styles.fieldInput}
|
className={styles.fieldInput}
|
||||||
|
|
@ -525,7 +547,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
const selectedRow = displayData[selectedIndex];
|
const selectedRow = displayData[selectedIndex];
|
||||||
handleDeleteSingle(selectedRow, selectedIndex);
|
handleDeleteSingle(selectedRow, selectedIndex);
|
||||||
} : undefined}
|
} : undefined}
|
||||||
onDeleteMultiple={selectedItems.size > 1 && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
onDeleteMultiple={(selectedItems.size > 1 || (selectedItems.size === displayData.length && displayData.length > 0)) && onDeleteMultiple ? handleDeleteMultiple : undefined}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
searchable={searchable}
|
searchable={searchable}
|
||||||
filterable={filterable}
|
filterable={filterable}
|
||||||
|
|
@ -559,7 +581,18 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
title={t('formgen.select.all', 'Select all items')}
|
title={t('formgen.select.all', 'Select all items')}
|
||||||
className={styles.selectAllCheckbox}
|
className={styles.selectAllCheckbox}
|
||||||
/>
|
/>
|
||||||
{sortable && (
|
{title && (
|
||||||
|
<h3 className={styles.listTitle}>{title}</h3>
|
||||||
|
)}
|
||||||
|
{title && data.length > 0 && (
|
||||||
|
<span className={styles.listCount}>({data.length})</span>
|
||||||
|
)}
|
||||||
|
{headerButton && (
|
||||||
|
<div className={styles.headerButtonWrapper}>
|
||||||
|
{headerButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sortable && (
|
||||||
<div className={styles.sortControls}>
|
<div className={styles.sortControls}>
|
||||||
{detectedFields.map(field => (
|
{detectedFields.map(field => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -583,7 +616,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
{/* List Items */}
|
{/* List Items */}
|
||||||
{displayData.length === 0 ? (
|
{displayData.length === 0 ? (
|
||||||
<div className={styles.emptyMessage}>
|
<div className={styles.emptyMessage}>
|
||||||
{t('formgen.empty', 'No data available')}
|
{emptyMessage || t('formgen.empty', 'No data available')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.itemsList}>
|
<div className={styles.itemsList}>
|
||||||
|
|
@ -704,7 +737,7 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
className={`${styles.itemField} ${customClassName}`}
|
className={`${styles.itemField} ${customClassName}`}
|
||||||
>
|
>
|
||||||
<label className={styles.fieldLabel}>{field.label}</label>
|
<label className={styles.fieldLabel}>{field.label}</label>
|
||||||
{renderFieldInput(field, cellValue, row, index)}
|
{renderFieldInput(field, cellValue, row)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Message } from '../MessagesTypes';
|
import { Message } from '../MessagesTypes';
|
||||||
import { formatTimestamp } from '../MessageUtils';
|
import { formatTimestamp } from '../MessageUtils';
|
||||||
import { DocumentItem, ActionInfo } from '../MessageParts';
|
import { DocumentItem, ActionInfo } from '../MessageParts';
|
||||||
|
|
@ -47,7 +49,62 @@ export const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
{/* Message content */}
|
{/* Message content */}
|
||||||
{message.message && (
|
{message.message && (
|
||||||
<div className={styles.messageContent}>
|
<div className={styles.messageContent}>
|
||||||
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,6 @@
|
||||||
.messageContent {
|
.messageContent {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,6 +173,322 @@
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Styling */
|
||||||
|
.markdownContent {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
.markdownH1,
|
||||||
|
.markdownH2,
|
||||||
|
.markdownH3,
|
||||||
|
.markdownH4,
|
||||||
|
.markdownH5,
|
||||||
|
.markdownH6 {
|
||||||
|
margin: 16px 0 8px 0;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH1 {
|
||||||
|
font-size: 24px;
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH2 {
|
||||||
|
font-size: 20px;
|
||||||
|
border-bottom: 1px solid var(--color-gray-disabled);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH4 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH5 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownH6 {
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownH1,
|
||||||
|
.messageUser .markdownH2,
|
||||||
|
.messageUser .markdownH3,
|
||||||
|
.messageUser .markdownH4,
|
||||||
|
.messageUser .markdownH5,
|
||||||
|
.messageUser .markdownH6 {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownH1 {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownH2 {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownH1,
|
||||||
|
.messageAssistant .markdownH2,
|
||||||
|
.messageAssistant .markdownH3,
|
||||||
|
.messageAssistant .markdownH4,
|
||||||
|
.messageAssistant .markdownH5,
|
||||||
|
.messageAssistant .markdownH6 {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownH1 {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownH2 {
|
||||||
|
border-bottom-color: var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paragraphs */
|
||||||
|
.markdownP {
|
||||||
|
margin: 8px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-line; /* Preserve line breaks */
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownP:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownP:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
.markdownUl,
|
||||||
|
.markdownOl {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownLi {
|
||||||
|
margin: 4px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownUl .markdownLi {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownOl .markdownLi {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.markdownTableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTable {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownTable {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownThead {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownThead {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTh {
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTh {
|
||||||
|
color: white;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownTh {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTd {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTd {
|
||||||
|
color: white;
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownTd {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-bottom-color: var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTr:last-child .markdownTd {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownTr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownTr:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.markdownCodeInline {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownCodeInline {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownCodeInline {
|
||||||
|
background-color: var(--color-highlight-gray);
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownPre {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownPre {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownPre {
|
||||||
|
background-color: var(--color-highlight-gray);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownCodeBlock {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquote */
|
||||||
|
.markdownBlockquote {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-left: 4px solid var(--color-primary);
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownBlockquote {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-left-color: rgba(255, 255, 255, 0.5);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownBlockquote {
|
||||||
|
background-color: var(--color-highlight-gray);
|
||||||
|
border-left-color: var(--color-primary);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strong and Emphasis */
|
||||||
|
.markdownStrong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownStrong {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownStrong {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownEm {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.markdownLink {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdownLink:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownLink {
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links in tables - rendered as text */
|
||||||
|
.markdownLinkText {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownLinkText {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownLinkText {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal Rule */
|
||||||
|
.markdownHr {
|
||||||
|
margin: 16px 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageUser .markdownHr {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messageAssistant .markdownHr {
|
||||||
|
border-top-color: var(--color-gray-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
.messageTimestamp {
|
.messageTimestamp {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { GenericPageData, PageButton, PageContent, resolveLanguageText, Settings
|
||||||
import { FormGenerator } from '../../components/FormGenerator';
|
import { FormGenerator } from '../../components/FormGenerator';
|
||||||
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
|
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
|
||||||
|
import { FormGeneratorList, FieldConfig } from '../../components/FormGenerator';
|
||||||
|
import { LuPlus } from 'react-icons/lu';
|
||||||
|
import type { ChatbotWorkflow } from '../../api/chatbotApi';
|
||||||
import { Popup } from '../../components/UiComponents/Popup';
|
import { Popup } from '../../components/UiComponents/Popup';
|
||||||
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
||||||
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
||||||
|
|
@ -199,11 +202,57 @@ const ContentRenderer: React.FC<{
|
||||||
return null; // Or return a loading indicator if desired
|
return null; // Or return a loading indicator if desired
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a dashboard layout pattern: messages, log, inputForm
|
// Check if this is a chatbot layout pattern: chatHistory, messages, inputForm
|
||||||
|
const hasChatHistory = visibleContents.some(c => c.type === 'chatHistory');
|
||||||
const hasMessages = visibleContents.some(c => c.type === 'messages');
|
const hasMessages = visibleContents.some(c => c.type === 'messages');
|
||||||
const hasLog = visibleContents.some(c => c.type === 'log');
|
|
||||||
const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
|
const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
|
||||||
const isDashboardLayout = hasMessages && hasLog && hasInputForm;
|
const isChatbotLayout = hasChatHistory && hasMessages && hasInputForm;
|
||||||
|
|
||||||
|
// Check if this is a dashboard layout pattern: messages, log, inputForm
|
||||||
|
const hasLog = visibleContents.some(c => c.type === 'log');
|
||||||
|
const isDashboardLayout = hasMessages && hasLog && hasInputForm && !isChatbotLayout;
|
||||||
|
|
||||||
|
if (isChatbotLayout) {
|
||||||
|
// Render chatbot two-column layout
|
||||||
|
const chatHistoryContent = visibleContents.find(c => c.type === 'chatHistory');
|
||||||
|
const messagesContent = visibleContents.find(c => c.type === 'messages');
|
||||||
|
const inputFormContent = visibleContents.find(c => c.type === 'inputForm');
|
||||||
|
const otherContents = visibleContents.filter(c =>
|
||||||
|
c.type !== 'chatHistory' && c.type !== 'messages' && c.type !== 'inputForm'
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.chatbotTwoColumnLayout}>
|
||||||
|
{/* Left column: Chat History */}
|
||||||
|
{chatHistoryContent && (
|
||||||
|
<div className={styles.chatbotHistoryColumn}>
|
||||||
|
{renderContent(chatHistoryContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Right column: Messages and Input Form */}
|
||||||
|
<div className={styles.chatbotChatColumn}>
|
||||||
|
{messagesContent && (
|
||||||
|
<div className={styles.chatbotMessagesCell}>
|
||||||
|
{renderContent(messagesContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{inputFormContent && (
|
||||||
|
<div className={styles.chatbotInputCell}>
|
||||||
|
{renderContent(inputFormContent)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Render any other content sections */}
|
||||||
|
{otherContents.map((content, index) => (
|
||||||
|
<React.Fragment key={content.id || `content-${content.type}-${index}`}>
|
||||||
|
{renderContent(content)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isDashboardLayout) {
|
if (isDashboardLayout) {
|
||||||
// Render dashboard grid layout
|
// Render dashboard grid layout
|
||||||
|
|
@ -1408,6 +1457,162 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'chatHistory': {
|
||||||
|
const chatHistoryConfig = content.chatHistoryConfig || {};
|
||||||
|
const threads: ChatbotWorkflow[] = (hookData as any)?.threads || [];
|
||||||
|
const selectedThreadId = (hookData as any)?.selectedThreadId || null;
|
||||||
|
const selectThread = (hookData as any)?.selectThread;
|
||||||
|
const startNewChat = (hookData as any)?.startNewChat;
|
||||||
|
const threadsLoading = (hookData as any)?.threadsLoading || false;
|
||||||
|
const threadsError = (hookData as any)?.threadsError || null;
|
||||||
|
|
||||||
|
if (!selectThread) {
|
||||||
|
console.warn('ChatHistoryList requires selectThread method from hookData');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format date function for relative time display
|
||||||
|
const formatDate = (timestamp?: number): string => {
|
||||||
|
if (!timestamp) return '';
|
||||||
|
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
|
if (diffMins < 1) return 'Just now';
|
||||||
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
|
|
||||||
|
// Format as date
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get thread preview function
|
||||||
|
const getThreadPreview = (thread: ChatbotWorkflow): string => {
|
||||||
|
if (thread.name) return thread.name;
|
||||||
|
return `Chat ${thread.id.slice(0, 8)}...`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Field configuration for ChatbotWorkflow
|
||||||
|
// First field: Thread preview (uses name field but formats it)
|
||||||
|
// Second field: Date (uses lastActivity field but formats it)
|
||||||
|
// Third field: Status
|
||||||
|
const fields: FieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Thread',
|
||||||
|
type: 'text',
|
||||||
|
formatter: (value: any, row: ChatbotWorkflow) => getThreadPreview(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastActivity',
|
||||||
|
label: 'Date',
|
||||||
|
type: 'timestamp',
|
||||||
|
formatter: (value: any, row: ChatbotWorkflow) => {
|
||||||
|
const timestamp = row.lastActivity || row.startedAt;
|
||||||
|
return formatDate(timestamp);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Status',
|
||||||
|
type: 'text',
|
||||||
|
formatter: (value: any, row: ChatbotWorkflow) => row.status || ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Handle error state
|
||||||
|
if (threadsError) {
|
||||||
|
return (
|
||||||
|
<div key={content.id} className={styles.chatHistorySection}>
|
||||||
|
<div className={styles.chatHistoryHeader}>
|
||||||
|
<h3 className={styles.chatHistoryTitle}>Chat History</h3>
|
||||||
|
{startNewChat && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={LuPlus}
|
||||||
|
onClick={startNewChat}
|
||||||
|
title="New Chat"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.chatHistoryError}>
|
||||||
|
{threadsError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = chatHistoryConfig.emptyMessage
|
||||||
|
? resolveLanguageText(chatHistoryConfig.emptyMessage, t)
|
||||||
|
: 'No chat history yet. Start a conversation to see it here.';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={content.id} className={styles.chatHistorySection}>
|
||||||
|
<FormGeneratorList<ChatbotWorkflow>
|
||||||
|
data={threads}
|
||||||
|
fields={fields}
|
||||||
|
title="Chat History"
|
||||||
|
searchable={false}
|
||||||
|
filterable={false}
|
||||||
|
sortable={false}
|
||||||
|
pagination={false}
|
||||||
|
selectable={true}
|
||||||
|
loading={threadsLoading}
|
||||||
|
onItemClick={(row) => selectThread(row.id)}
|
||||||
|
onItemSelect={(selectedRows) => {
|
||||||
|
// Handle multiselect - select first item when multiple selected
|
||||||
|
if (selectedRows.length > 0 && selectedRows[0]) {
|
||||||
|
selectThread(selectedRows[0].id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDeleteMultiple={(selectedRows) => {
|
||||||
|
// Handle bulk delete
|
||||||
|
selectedRows.forEach(row => {
|
||||||
|
// Delete logic handled by action button
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
getItemDataAttributes={(row) => ({
|
||||||
|
'selected-thread-id': selectedThreadId === row.id ? 'true' : 'false',
|
||||||
|
'thread-id': row.id
|
||||||
|
})}
|
||||||
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'delete',
|
||||||
|
idField: 'id',
|
||||||
|
operationName: 'handleDelete',
|
||||||
|
loadingStateName: 'deletingItems',
|
||||||
|
onAction: async (row) => {
|
||||||
|
// If deleted thread was selected, start new chat
|
||||||
|
if (selectedThreadId === row.id && startNewChat) {
|
||||||
|
startNewChat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
hookData={hookData}
|
||||||
|
className={styles.chatHistoryList}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
headerButton={startNewChat ? (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={LuPlus}
|
||||||
|
onClick={startNewChat}
|
||||||
|
title="New Chat"
|
||||||
|
className={styles.chatHistoryNewButton}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
src/core/PageManager/data/pages/chatbot.ts
Normal file
77
src/core/PageManager/data/pages/chatbot.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Page behavior
|
||||||
|
persistent: true,
|
||||||
|
preserveState: true,
|
||||||
|
preload: true,
|
||||||
|
moduleEnabled: true,
|
||||||
|
|
||||||
|
// Lifecycle hooks
|
||||||
|
onActivate: async () => {
|
||||||
|
if (import.meta.env.DEV) console.log('Chatbot activated - state preserved');
|
||||||
|
},
|
||||||
|
onDeactivate: async () => {
|
||||||
|
if (import.meta.env.DEV) console.log('Chatbot deactivated - keeping state');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -9,6 +9,7 @@ export { speechPageData } from './speech';
|
||||||
export { settingsPageData } from './settings';
|
export { settingsPageData } from './settings';
|
||||||
export { pekPageData } from './pek';
|
export { pekPageData } from './pek';
|
||||||
export { pekTablesPageData } from './pek-tables';
|
export { pekTablesPageData } from './pek-tables';
|
||||||
|
export { chatbotPageData } from './chatbot';
|
||||||
|
|
||||||
// Import all page data
|
// Import all page data
|
||||||
import { dashboardPageData } from './dashboard';
|
import { dashboardPageData } from './dashboard';
|
||||||
|
|
@ -21,6 +22,7 @@ import { speechPageData } from './speech';
|
||||||
import { settingsPageData } from './settings';
|
import { settingsPageData } from './settings';
|
||||||
import { pekPageData } from './pek';
|
import { pekPageData } from './pek';
|
||||||
import { pekTablesPageData } from './pek-tables';
|
import { pekTablesPageData } from './pek-tables';
|
||||||
|
import { chatbotPageData } from './chatbot';
|
||||||
|
|
||||||
// Array of all page data
|
// Array of all page data
|
||||||
export const allPageData = [
|
export const allPageData = [
|
||||||
|
|
@ -34,6 +36,7 @@ export const allPageData = [
|
||||||
settingsPageData,
|
settingsPageData,
|
||||||
pekPageData,
|
pekPageData,
|
||||||
pekTablesPageData,
|
pekTablesPageData,
|
||||||
|
chatbotPageData,
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export interface SettingsConfig {
|
||||||
// Content section for paragraphs
|
// Content section for paragraphs
|
||||||
export interface PageContent {
|
export interface PageContent {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log';
|
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'chatHistory';
|
||||||
content?: string | LanguageText; // Optional for dividers
|
content?: string | LanguageText; // Optional for dividers
|
||||||
level?: number; // For headings (1-6)
|
level?: number; // For headings (1-6)
|
||||||
items?: (string | LanguageText)[]; // For lists
|
items?: (string | LanguageText)[]; // For lists
|
||||||
|
|
@ -146,6 +146,10 @@ export interface PageContent {
|
||||||
logConfig?: {
|
logConfig?: {
|
||||||
emptyMessage?: string | LanguageText;
|
emptyMessage?: string | LanguageText;
|
||||||
};
|
};
|
||||||
|
// Chat history-specific properties
|
||||||
|
chatHistoryConfig?: {
|
||||||
|
emptyMessage?: string | LanguageText;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic hook interface for data fetching
|
// Generic hook interface for data fetching
|
||||||
|
|
|
||||||
623
src/hooks/useChatbot.ts
Normal file
623
src/hooks/useChatbot.ts
Normal file
|
|
@ -0,0 +1,623 @@
|
||||||
|
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
|
import { useApiRequest } from './useApi';
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
if (thinkingMessageIdRef.current) {
|
||||||
|
const thinkingId = thinkingMessageIdRef.current;
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
return prevMessages.filter(m => m.id !== thinkingId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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 old thinking message if it exists
|
||||||
|
const filtered = prevMessages.filter(m => m.id !== thinkingId);
|
||||||
|
// 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) => {
|
||||||
|
// 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) => {
|
||||||
|
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 || '';
|
||||||
|
|
||||||
|
if (logMessage) {
|
||||||
|
// Add log immediately (progressive display)
|
||||||
|
addLogToThinkingMessage(logMessage);
|
||||||
|
}
|
||||||
|
} 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;
|
||||||
|
|
||||||
|
if (processedMessageIdsRef.current.has(messageId)) {
|
||||||
|
// Update existing message - clear thinking message first
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
let filtered = prevMessages;
|
||||||
|
if (thinkingId) {
|
||||||
|
filtered = prevMessages.filter(m => m.id !== thinkingId);
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = filtered.findIndex(m => m.id === messageId);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
const updated = [...filtered];
|
||||||
|
updated[existingIndex] = messageData as Message;
|
||||||
|
return updated.sort(sortMessages);
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Add new message - clear thinking message first
|
||||||
|
processedMessageIdsRef.current.add(messageId);
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
// Remove thinking message BEFORE adding new message (same state update)
|
||||||
|
let filtered = prevMessages;
|
||||||
|
if (thinkingId) {
|
||||||
|
filtered = prevMessages.filter(m => m.id !== thinkingId);
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new message
|
||||||
|
const updated = [...filtered, 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);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Prepare request body
|
||||||
|
const requestBody: StartChatbotRequest = {
|
||||||
|
prompt: trimmedInput,
|
||||||
|
userLanguage: 'en',
|
||||||
|
...(workflowId && { workflowId })
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track if workflow was created in this request
|
||||||
|
let workflowCreated = false;
|
||||||
|
|
||||||
|
// Clear thinking message when starting a new request
|
||||||
|
clearThinkingMessage();
|
||||||
|
|
||||||
|
// Start SSE stream
|
||||||
|
await startChatbotStreamApi(
|
||||||
|
requestBody,
|
||||||
|
(item: ChatDataItem) => {
|
||||||
|
// Check if stream was aborted
|
||||||
|
if (abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Stream completed
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
setIsRunning(false);
|
||||||
|
setInputValue(''); // Clear input on completion
|
||||||
|
// Clear thinking message on completion if no final message was received
|
||||||
|
setTimeout(() => {
|
||||||
|
clearThinkingMessage();
|
||||||
|
// Refresh threads list after message completion (silently, without loading state)
|
||||||
|
loadThreadsSilently();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads]);
|
||||||
|
|
||||||
|
// 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('');
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
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('');
|
||||||
|
thinkingLogsRef.current = [];
|
||||||
|
thinkingMessageIdRef.current = null;
|
||||||
|
clearProcessedMessages();
|
||||||
|
}, [clearProcessedMessages]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (streamAbortControllerRef.current) {
|
||||||
|
streamAbortControllerRef.current.abort();
|
||||||
|
streamAbortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
logQueueRef.current = [];
|
||||||
|
isProcessingLogsRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createChatbotHook() {
|
||||||
|
return () => useChatbot();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -311,6 +311,177 @@
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chatbot two-column layout */
|
||||||
|
.chatbotTwoColumnLayout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 3fr;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
height: calc(100vh);
|
||||||
|
max-height: calc(100vh);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotHistoryColumn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotChatColumn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotMessagesCell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatbotInputCell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force chat history list styling */
|
||||||
|
.chatHistorySection :global(.listContainer) {
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem) {
|
||||||
|
border: none !important;
|
||||||
|
border-top: 1px solid var(--color-medium-gray) !important;
|
||||||
|
border-bottom: 1px solid var(--color-medium-gray) !important;
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0.875rem 0.75rem !important;
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem:first-child) {
|
||||||
|
border-top: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem:hover) {
|
||||||
|
border-color: var(--color-medium-gray) !important;
|
||||||
|
border-top-color: var(--color-medium-gray) !important;
|
||||||
|
border-bottom-color: var(--color-medium-gray) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistorySection :global(.listItem[data-selected-thread-id="true"]) {
|
||||||
|
border-left: none !important;
|
||||||
|
border-right: none !important;
|
||||||
|
padding-left: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-medium-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryTitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryCount {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--object-radius-small);
|
||||||
|
color: var(--color-gray);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton:hover {
|
||||||
|
background: var(--color-highlight-gray);
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton:active {
|
||||||
|
background: var(--color-medium-gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryNewButton svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryEmpty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--color-gray);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatHistoryError {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
color: var(--color-red);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.scrollableContent {
|
.scrollableContent {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue