feat: completely build up althaus page

This commit is contained in:
Ida Dittrich 2026-01-05 06:18:49 +01:00
parent 5f22c7be77
commit eb280dbae1
14 changed files with 3399 additions and 63 deletions

1473
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -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
View 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;
}
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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();
@ -528,7 +533,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}
@ -562,7 +567,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
@ -586,7 +602,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}>
@ -707,7 +723,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>
); );
})} })}

View file

@ -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>
)} )}

View file

@ -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;

View file

@ -3,6 +3,9 @@ import { GenericPageData, PageButton, PageContent, resolveLanguageText, Settings
import { FormGenerator } from '../../components/FormGenerator'; import { FormGenerator } from '../../components/FormGenerator';
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents'; import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
import { FormGeneratorList, FieldConfig } from '../../components/FormGenerator';
import { LuPlus } from 'react-icons/lu';
import type { ChatbotWorkflow } from '../../api/chatbotApi';
import { Popup } from '../../components/UiComponents/Popup'; import { Popup } from '../../components/UiComponents/Popup';
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList'; import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect'; import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
@ -200,11 +203,57 @@ const ContentRenderer: React.FC<{
return null; // Or return a loading indicator if desired return null; // Or return a loading indicator if desired
} }
// Check if this is a dashboard layout pattern: messages, log, inputForm // Check if this is a chatbot layout pattern: chatHistory, messages, inputForm
const hasChatHistory = visibleContents.some(c => c.type === 'chatHistory');
const hasMessages = visibleContents.some(c => c.type === 'messages'); const hasMessages = visibleContents.some(c => c.type === 'messages');
const hasLog = visibleContents.some(c => c.type === 'log');
const hasInputForm = visibleContents.some(c => c.type === 'inputForm'); const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
const isDashboardLayout = hasMessages && hasLog && hasInputForm; const isChatbotLayout = hasChatHistory && hasMessages && hasInputForm;
// Check if this is a dashboard layout pattern: messages, log, inputForm
const hasLog = visibleContents.some(c => c.type === 'log');
const isDashboardLayout = hasMessages && hasLog && hasInputForm && !isChatbotLayout;
if (isChatbotLayout) {
// Render chatbot two-column layout
const chatHistoryContent = visibleContents.find(c => c.type === 'chatHistory');
const messagesContent = visibleContents.find(c => c.type === 'messages');
const inputFormContent = visibleContents.find(c => c.type === 'inputForm');
const otherContents = visibleContents.filter(c =>
c.type !== 'chatHistory' && c.type !== 'messages' && c.type !== 'inputForm'
);
return (
<>
<div className={styles.chatbotTwoColumnLayout}>
{/* Left column: Chat History */}
{chatHistoryContent && (
<div className={styles.chatbotHistoryColumn}>
{renderContent(chatHistoryContent)}
</div>
)}
{/* Right column: Messages and Input Form */}
<div className={styles.chatbotChatColumn}>
{messagesContent && (
<div className={styles.chatbotMessagesCell}>
{renderContent(messagesContent)}
</div>
)}
{inputFormContent && (
<div className={styles.chatbotInputCell}>
{renderContent(inputFormContent)}
</div>
)}
</div>
</div>
{/* Render any other content sections */}
{otherContents.map((content, index) => (
<React.Fragment key={content.id || `content-${content.type}-${index}`}>
{renderContent(content)}
</React.Fragment>
))}
</>
);
}
if (isDashboardLayout) { if (isDashboardLayout) {
// Render dashboard grid layout // Render dashboard grid layout
@ -1465,6 +1514,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;
} }

View 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');
}
};

View file

@ -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,
]; ];

View file

@ -123,7 +123,7 @@ export interface SettingsConfig {
// Content section for paragraphs // Content section for paragraphs
export interface PageContent { export interface PageContent {
id: string; id: string;
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log'; type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'chatHistory';
content?: string | LanguageText; // Optional for dividers content?: string | LanguageText; // Optional for dividers
level?: number; // For headings (1-6) level?: number; // For headings (1-6)
items?: (string | LanguageText)[]; // For lists items?: (string | LanguageText)[]; // For lists
@ -148,6 +148,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
View 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();
}

View file

@ -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;