Merge pull request 'int' (#2) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Reviewed-on: #2
This commit is contained in:
commit
991952dde9
29 changed files with 121 additions and 2474 deletions
|
|
@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, SttBenchmarkPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
|
@ -134,7 +134,6 @@ function App() {
|
||||||
<Route path="rag-inventory" element={<RagInventoryPage />} />
|
<Route path="rag-inventory" element={<RagInventoryPage />} />
|
||||||
|
|
||||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
|
||||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||||
<Route path="speech" element={<Navigate to="/" replace />} />
|
<Route path="speech" element={<Navigate to="/" replace />} />
|
||||||
|
|
||||||
|
|
@ -225,7 +224,6 @@ function App() {
|
||||||
<Route path="languages" element={null} />
|
<Route path="languages" element={null} />
|
||||||
<Route path="database-health" element={null} />
|
<Route path="database-health" element={null} />
|
||||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||||
<Route path="stt-benchmark" element={<SttBenchmarkPage />} />
|
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
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; // Optional - not in ChatbotConversation
|
|
||||||
featureInstanceId?: string; // From ChatbotConversation
|
|
||||||
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' | 'stopped' | 'status' | 'chunk';
|
|
||||||
createdAt?: number;
|
|
||||||
item?: Message | any;
|
|
||||||
label?: string; // For status events
|
|
||||||
content?: string; // For chunk events (token-by-token streaming)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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/{instanceId}/start/stream
|
|
||||||
*
|
|
||||||
* @param instanceId - Feature Instance ID
|
|
||||||
* @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(
|
|
||||||
instanceId: string,
|
|
||||||
requestBody: StartChatbotRequest,
|
|
||||||
onEvent: SSEEventHandler,
|
|
||||||
onError?: (error: Error) => void,
|
|
||||||
onComplete?: () => void
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
// Prepare request body
|
|
||||||
console.log('[startChatbotStreamApi] instanceId:', instanceId);
|
|
||||||
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
|
|
||||||
|
|
||||||
const body: any = {
|
|
||||||
prompt: requestBody.prompt,
|
|
||||||
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
|
|
||||||
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
|
|
||||||
...(requestBody.metadata && { metadata: requestBody.metadata })
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
|
|
||||||
|
|
||||||
// Add workflowId to query params if provided
|
|
||||||
const url = requestBody.workflowId
|
|
||||||
? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
|
|
||||||
: `/api/chatbot/${instanceId}/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);
|
|
||||||
console.log('[SSE] Received event:', item.type, item);
|
|
||||||
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/{instanceId}/stop/{workflowId}
|
|
||||||
*/
|
|
||||||
export async function stopChatbotApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
workflowId: string
|
|
||||||
): Promise<ChatbotWorkflow> {
|
|
||||||
console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId });
|
|
||||||
const data = await request({
|
|
||||||
url: `/api/chatbot/${instanceId}/stop/${workflowId}`,
|
|
||||||
method: 'post'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[stopChatbotApi] Stop response:', data);
|
|
||||||
return data as ChatbotWorkflow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get chatbot threads/workflows
|
|
||||||
* Endpoint: GET /api/chatbot/{instanceId}/threads
|
|
||||||
*/
|
|
||||||
export async function getChatbotThreadsApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
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] instanceId: ${instanceId}, params:`, requestParams);
|
|
||||||
|
|
||||||
const data = await request({
|
|
||||||
url: `/api/chatbot/${instanceId}/threads`,
|
|
||||||
method: 'get',
|
|
||||||
params: requestParams
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
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.pagination ?? data.metadata ?? {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific chatbot thread/workflow with its chat data
|
|
||||||
* Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id}
|
|
||||||
*
|
|
||||||
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
|
|
||||||
*
|
|
||||||
* @param request - API request function
|
|
||||||
* @param instanceId - Feature Instance ID
|
|
||||||
* @param workflowId - ID of the workflow to fetch
|
|
||||||
* @returns Object containing workflow details and chatData with items array
|
|
||||||
*/
|
|
||||||
export async function getChatbotThreadApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
workflowId: string
|
|
||||||
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
|
|
||||||
console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`);
|
|
||||||
|
|
||||||
const data = await request({
|
|
||||||
url: `/api/chatbot/${instanceId}/threads`,
|
|
||||||
method: 'get',
|
|
||||||
params: { workflowId }
|
|
||||||
}) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } };
|
|
||||||
|
|
||||||
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: ChatDataItem) => item?.type).filter(Boolean) : []
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
workflow: data.workflow,
|
|
||||||
chatData: data.chatData || { items: [] }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a chatbot workflow
|
|
||||||
* Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId}
|
|
||||||
*
|
|
||||||
* @param request - API request function
|
|
||||||
* @param instanceId - Feature Instance ID
|
|
||||||
* @param workflowId - ID of the workflow to delete
|
|
||||||
* @returns Success status
|
|
||||||
*/
|
|
||||||
export async function deleteChatbotWorkflowApi(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
instanceId: string,
|
|
||||||
workflowId: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await request({
|
|
||||||
url: `/api/chatbot/${instanceId}/${workflowId}`,
|
|
||||||
method: 'delete'
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error deleting chatbot workflow:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -14,8 +14,6 @@ import type {
|
||||||
InstancePermissions,
|
InstancePermissions,
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
} from '../types/mandate';
|
} from '../types/mandate';
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MOCK DATA (Temporär bis Backend bereit)
|
// MOCK DATA (Temporär bis Backend bereit)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -178,43 +176,6 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||||
// Get the actual data (response.data contains the FeaturesMyResponse)
|
// Get the actual data (response.data contains the FeaturesMyResponse)
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// DEBUG: Log all chatbot instances and their permissions
|
|
||||||
console.log('🔍 [DEBUG] featuresApi: Full response received', {
|
|
||||||
response,
|
|
||||||
data,
|
|
||||||
hasMandates: !!data?.mandates,
|
|
||||||
mandateCount: data?.mandates?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data?.mandates) {
|
|
||||||
data.mandates.forEach(mandate => {
|
|
||||||
mandate.features.forEach(feature => {
|
|
||||||
if (feature.code === 'chatbot') {
|
|
||||||
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
|
||||||
mandateId: mandate.id,
|
|
||||||
mandateName: mandateDisplayLabel(mandate),
|
|
||||||
featureCode: feature.code,
|
|
||||||
instanceCount: feature.instances.length,
|
|
||||||
});
|
|
||||||
feature.instances.forEach(instance => {
|
|
||||||
console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', {
|
|
||||||
instanceId: instance.id,
|
|
||||||
instanceLabel: instance.instanceLabel,
|
|
||||||
featureCode: instance.featureCode,
|
|
||||||
userRoles: instance.userRoles,
|
|
||||||
permissions: instance.permissions,
|
|
||||||
views: instance.permissions?.views,
|
|
||||||
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
|
||||||
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] ||
|
|
||||||
instance.permissions?.views?.['ui.feature.chatbot.conversations'] ||
|
|
||||||
instance.permissions?.views?.['_all'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ featuresApi: Loaded features:', {
|
console.log('✅ featuresApi: Loaded features:', {
|
||||||
mandateCount: data?.mandates?.length || 0,
|
mandateCount: data?.mandates?.length || 0,
|
||||||
totalInstances: data?.mandates
|
totalInstances: data?.mandates
|
||||||
|
|
@ -239,7 +200,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
||||||
return [
|
return [
|
||||||
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
||||||
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
||||||
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ export interface MessageDocument {
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
workflowId?: string; // Legacy / backward compat
|
workflowId?: string; // Legacy / backward compat
|
||||||
conversationId?: string; // New - from ChatbotMessage
|
conversationId?: string; // New - from chat conversation message
|
||||||
parentMessageId?: string;
|
parentMessageId?: string;
|
||||||
documents?: MessageDocument[];
|
documents?: MessageDocument[];
|
||||||
documentsLabel?: string;
|
documentsLabel?: string;
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeIcon,
|
|
||||||
.neutralizeIcon {
|
.neutralizeIcon {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
@ -60,7 +59,6 @@
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scopeIcon:hover,
|
|
||||||
.neutralizeIcon:hover {
|
.neutralizeIcon:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: var(--bg-hover, rgba(0, 0, 0, 0.06));
|
background: var(--bg-hover, rgba(0, 0, 0, 0.06));
|
||||||
|
|
@ -139,7 +137,6 @@
|
||||||
.fileRow:hover {
|
.fileRow:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
.scopeIcon:hover,
|
|
||||||
.neutralizeIcon:hover {
|
.neutralizeIcon:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ import {
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
import { SiJira } from 'react-icons/si';
|
import { SiJira } from 'react-icons/si';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree';
|
import type { TreeNode, TreeNodeProvider } from '../FormGenerator/FormGeneratorTree';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Backend contract types
|
// Backend contract types
|
||||||
|
|
@ -180,14 +180,14 @@ function _mapBackendNode(
|
||||||
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
// neutralizeFields list). Scope and RAG are not field-level concepts.
|
||||||
node.neutralize = n.effectiveNeutralize;
|
node.neutralize = n.effectiveNeutralize;
|
||||||
} else if (_isFdsKind(n.kind)) {
|
} else if (_isFdsKind(n.kind)) {
|
||||||
// FDS records have neutralize + ragIndexEnabled, but no scope.
|
|
||||||
node.neutralize = n.effectiveNeutralize;
|
node.neutralize = n.effectiveNeutralize;
|
||||||
if (n.supportsRag) {
|
if (n.supportsRag) {
|
||||||
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
|
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// DataSource family carries the full three-flag set.
|
// DataSource family: neutralize + ragIndexEnabled only.
|
||||||
node.scope = n.effectiveScope as ScopeValue | 'mixed';
|
// Scope was removed (personal sources must not be shared across
|
||||||
|
// scopes — privacy requirement, 2026-06).
|
||||||
node.neutralize = n.effectiveNeutralize;
|
node.neutralize = n.effectiveNeutralize;
|
||||||
if (n.supportsRag) {
|
if (n.supportsRag) {
|
||||||
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
|
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
|
||||||
|
|
@ -287,7 +287,7 @@ export function createUdbSourcesProvider(
|
||||||
* permission check, and applies the cascade-reset. */
|
* permission check, and applies the cascade-reset. */
|
||||||
async function _patchFlag(
|
async function _patchFlag(
|
||||||
ids: string[],
|
ids: string[],
|
||||||
flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
|
flag: 'neutralize' | 'ragIndexEnabled',
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const nodeKey of ids) {
|
for (const nodeKey of ids) {
|
||||||
|
|
@ -314,10 +314,8 @@ export function createUdbSourcesProvider(
|
||||||
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
|
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
|
||||||
},
|
},
|
||||||
|
|
||||||
canPatchScope(node) {
|
canPatchScope(_node) {
|
||||||
const data = node.data;
|
return false;
|
||||||
// Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
|
|
||||||
return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
canPatchNeutralize(node) {
|
canPatchNeutralize(node) {
|
||||||
|
|
@ -331,11 +329,8 @@ export function createUdbSourcesProvider(
|
||||||
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
|
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
|
||||||
},
|
},
|
||||||
|
|
||||||
async patchScope(ids, scope, _cascadeChildren) {
|
async patchScope(_ids, _scope, _cascadeChildren) {
|
||||||
// Backend cascades NULL on descendants automatically based on the
|
// Scope removed from personal sources (privacy, 2026-06). No-op.
|
||||||
// existence of explicit child records; the cascadeChildren flag is the
|
|
||||||
// FilesTab convention and is irrelevant here.
|
|
||||||
await _patchFlag(ids, 'scope', scope);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async patchNeutralize(ids, neutralize) {
|
async patchNeutralize(ids, neutralize) {
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps DS-family backend nodes to TreeNode shape with all three flags', async () => {
|
it('maps DS-family backend nodes to TreeNode shape with neutralize + rag (no scope)', async () => {
|
||||||
const conn = _makeBackendNode();
|
const conn = _makeBackendNode();
|
||||||
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
@ -138,7 +138,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
const tn = result[0];
|
const tn = result[0];
|
||||||
expect(tn.id).toBe('conn|c1');
|
expect(tn.id).toBe('conn|c1');
|
||||||
expect(tn.scope).toBe('personal');
|
expect(tn.scope).toBeUndefined();
|
||||||
expect(tn.neutralize).toBe(false);
|
expect(tn.neutralize).toBe(false);
|
||||||
expect(tn.ragIndexEnabled).toBe(false);
|
expect(tn.ragIndexEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
@ -193,7 +193,16 @@ describe('UdbSourcesProvider.canPatch*', () => {
|
||||||
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
expect(provider.canPatchRagIndex?.(synthNode)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('canPatchScope is false for any FDS kind', async () => {
|
it('canPatchScope is false for all node kinds (scope removed from Sources)', async () => {
|
||||||
|
apiMock.post.mockResolvedValue({
|
||||||
|
data: { nodesByParent: { 'personalRoot': [_makeBackendNode()] } },
|
||||||
|
});
|
||||||
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
const [conn] = await provider.loadChildren('personalRoot', 'own');
|
||||||
|
expect(provider.canPatchScope?.(conn)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canPatch neutralize and rag on FDS table', async () => {
|
||||||
apiMock.post.mockResolvedValue({
|
apiMock.post.mockResolvedValue({
|
||||||
data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
|
data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
|
||||||
});
|
});
|
||||||
|
|
@ -221,16 +230,12 @@ describe('UdbSourcesProvider.canPatch*', () => {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe('UdbSourcesProvider.patchScope', () => {
|
describe('UdbSourcesProvider.patchScope', () => {
|
||||||
it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => {
|
it('is a no-op (scope removed from personal sources, 2026-06)', async () => {
|
||||||
apiMock.post.mockResolvedValue({ data: {} });
|
|
||||||
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
|
||||||
|
|
||||||
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
await provider.patchScope?.(['conn|c1'], 'mandate', true);
|
||||||
|
|
||||||
expect(apiMock.post).toHaveBeenCalledWith(
|
expect(apiMock.post).not.toHaveBeenCalled();
|
||||||
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`,
|
|
||||||
{ value: 'mandate' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
|
||||||
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
|
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
|
||||||
scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/,
|
scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/,
|
||||||
requireMandateForMount: false,
|
requireMandateForMount: false,
|
||||||
render: ({ instanceId, scopeKey }) => (
|
render: ({ mandateId, instanceId, scopeKey }) => (
|
||||||
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
|
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} persistentMandateId={mandateId} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||||
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, FaMicrophone,
|
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
|
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
|
||||||
|
|
@ -88,8 +88,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.database-health': <FaDatabase />,
|
'page.admin.database-health': <FaDatabase />,
|
||||||
'page.admin.demoConfig': <FaCubes />,
|
'page.admin.demoConfig': <FaCubes />,
|
||||||
'page.admin.demo-config': <FaCubes />,
|
'page.admin.demo-config': <FaCubes />,
|
||||||
'page.admin.sttBenchmark': <FaMicrophone />,
|
|
||||||
'page.admin.stt-benchmark': <FaMicrophone />,
|
|
||||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||||
'page.admin.mandateWizard': <FaHatWizard />,
|
'page.admin.mandateWizard': <FaHatWizard />,
|
||||||
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
||||||
|
|
@ -137,8 +135,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.graphicalEditor': <FaProjectDiagram />,
|
'feature.graphicalEditor': <FaProjectDiagram />,
|
||||||
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
||||||
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
|
||||||
'feature.chatbot': <FaComments />,
|
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
||||||
// Feature pages - Workspace
|
// Feature pages - Workspace
|
||||||
|
|
|
||||||
|
|
@ -1,486 +0,0 @@
|
||||||
/**
|
|
||||||
* useChatbot Hook
|
|
||||||
*
|
|
||||||
* Hook for managing chatbot conversations, messages, and chat functionality.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import { useApiRequest } from './useApi';
|
|
||||||
import {
|
|
||||||
getChatbotThreadsApi,
|
|
||||||
getChatbotThreadApi,
|
|
||||||
startChatbotStreamApi,
|
|
||||||
stopChatbotApi,
|
|
||||||
deleteChatbotWorkflowApi,
|
|
||||||
type ChatbotWorkflow,
|
|
||||||
type ChatDataItem,
|
|
||||||
type StartChatbotRequest
|
|
||||||
} from '../api/chatbotApi';
|
|
||||||
import { Message, getConversationId } from '../components/UiComponents/Messages/MessagesTypes';
|
|
||||||
import { useInstanceId } from './useCurrentInstance';
|
|
||||||
|
|
||||||
export interface ChatbotHookReturn {
|
|
||||||
// Threads/Conversations
|
|
||||||
threads: ChatbotWorkflow[];
|
|
||||||
selectedThreadId: string | null;
|
|
||||||
loadingThreads: boolean;
|
|
||||||
error: string | null;
|
|
||||||
|
|
||||||
// Messages
|
|
||||||
messages: Message[];
|
|
||||||
loadingMessages: boolean;
|
|
||||||
|
|
||||||
// Current workflow state
|
|
||||||
currentWorkflowId: string | null;
|
|
||||||
isStreaming: boolean;
|
|
||||||
streamingStatus: string | null; // Current streaming status message
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
selectThread: (workflowId: string) => Promise<void>;
|
|
||||||
createNewThread: () => void;
|
|
||||||
sendMessage: (input: string, files?: Array<{ id: string; name: string }>) => Promise<void>;
|
|
||||||
stopStreaming: () => Promise<void>;
|
|
||||||
deleteThread: (workflowId: string) => Promise<void>;
|
|
||||||
refreshThreads: () => Promise<void>;
|
|
||||||
|
|
||||||
// Input form state
|
|
||||||
inputValue: string;
|
|
||||||
setInputValue: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main chatbot hook
|
|
||||||
*/
|
|
||||||
export function useChatbot(): ChatbotHookReturn {
|
|
||||||
const { request } = useApiRequest();
|
|
||||||
const instanceId = useInstanceId();
|
|
||||||
|
|
||||||
// Threads state
|
|
||||||
const [threads, setThreads] = useState<ChatbotWorkflow[]>([]);
|
|
||||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
|
||||||
const [loadingThreads, setLoadingThreads] = useState(false);
|
|
||||||
|
|
||||||
// Messages state
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
|
||||||
|
|
||||||
// Current workflow state
|
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
|
||||||
const [streamingStatus, setStreamingStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Input state
|
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
|
|
||||||
// Ref to track if component is mounted
|
|
||||||
const isMountedRef = useRef(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true;
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load threads
|
|
||||||
const refreshThreads = useCallback(async () => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
|
|
||||||
setLoadingThreads(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await getChatbotThreadsApi(request, instanceId);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setThreads(result.items || []);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading threads:', err);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(err.message || 'Fehler beim Laden der Konversationen');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoadingThreads(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [request, instanceId]);
|
|
||||||
|
|
||||||
// Load messages for a thread
|
|
||||||
const loadThreadMessages = useCallback(async (workflowId: string) => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
|
|
||||||
setLoadingMessages(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const result = await getChatbotThreadApi(request, instanceId, workflowId);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
// Extract messages from chatData items
|
|
||||||
const messageItems = (result.chatData?.items || [])
|
|
||||||
.filter((item: ChatDataItem) => item.type === 'message')
|
|
||||||
.map((item: ChatDataItem) => item.item as Message);
|
|
||||||
|
|
||||||
setMessages(messageItems);
|
|
||||||
setCurrentWorkflowId(workflowId);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading thread messages:', err);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(err.message || 'Fehler beim Laden der Nachrichten');
|
|
||||||
setMessages([]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setLoadingMessages(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [request, instanceId]);
|
|
||||||
|
|
||||||
// Select a thread
|
|
||||||
const selectThread = useCallback(async (workflowId: string) => {
|
|
||||||
setSelectedThreadId(workflowId);
|
|
||||||
await loadThreadMessages(workflowId);
|
|
||||||
}, [loadThreadMessages]);
|
|
||||||
|
|
||||||
// Create new thread
|
|
||||||
const createNewThread = useCallback(() => {
|
|
||||||
setSelectedThreadId(null);
|
|
||||||
setMessages([]);
|
|
||||||
setCurrentWorkflowId(null);
|
|
||||||
setInputValue('');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Send message
|
|
||||||
const sendMessage = useCallback(async (
|
|
||||||
input: string,
|
|
||||||
files?: Array<{ id: string; name: string }>
|
|
||||||
) => {
|
|
||||||
if (!input.trim() || isStreaming || !instanceId) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
setIsStreaming(true);
|
|
||||||
setStreamingStatus(null); // Reset status
|
|
||||||
|
|
||||||
// Store the input message content to track duplicates
|
|
||||||
const inputMessageContent = input.trim();
|
|
||||||
|
|
||||||
// Add user message immediately for better UX
|
|
||||||
const tempUserMessageId = `temp-user-${Date.now()}`;
|
|
||||||
const userMessage: Message = {
|
|
||||||
id: tempUserMessageId,
|
|
||||||
workflowId: currentWorkflowId || undefined,
|
|
||||||
conversationId: currentWorkflowId || undefined,
|
|
||||||
role: 'user',
|
|
||||||
message: inputMessageContent,
|
|
||||||
publishedAt: Date.now()
|
|
||||||
};
|
|
||||||
|
|
||||||
setMessages(prev => [...prev, userMessage]);
|
|
||||||
setInputValue('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const requestBody: StartChatbotRequest = {
|
|
||||||
prompt: input,
|
|
||||||
workflowId: currentWorkflowId || undefined,
|
|
||||||
listFileId: files?.map(f => f.id),
|
|
||||||
userLanguage: navigator.language || 'de'
|
|
||||||
};
|
|
||||||
|
|
||||||
let newWorkflowId: string | null = null;
|
|
||||||
|
|
||||||
await startChatbotStreamApi(
|
|
||||||
instanceId,
|
|
||||||
requestBody,
|
|
||||||
(item: ChatDataItem) => {
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
|
|
||||||
// Handle stopped event
|
|
||||||
if (item.type === 'stopped') {
|
|
||||||
console.log('Received stopped event from backend');
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingStatus(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle status event (streaming progress updates)
|
|
||||||
if (item.type === 'status') {
|
|
||||||
const statusLabel = item.label || (item.item as any)?.label || '';
|
|
||||||
console.log('Received status update:', statusLabel);
|
|
||||||
setStreamingStatus(statusLabel);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle chunk events (ChatGPT-like token-by-token streaming)
|
|
||||||
if (item.type === 'chunk' && item.content) {
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last?.role === 'assistant') {
|
|
||||||
return prev.map(m =>
|
|
||||||
m.id === last!.id ? { ...m, message: (m.message || '') + item.content } : m
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [...prev, {
|
|
||||||
id: `streaming-${currentWorkflowId || 'new'}-${Date.now()}`,
|
|
||||||
workflowId: currentWorkflowId || undefined,
|
|
||||||
conversationId: currentWorkflowId || undefined,
|
|
||||||
role: 'assistant' as const,
|
|
||||||
message: item.content,
|
|
||||||
publishedAt: Date.now()
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle workflow update (includes name updates from background task)
|
|
||||||
if (item.type === 'stat' && item.item?.id) {
|
|
||||||
newWorkflowId = item.item.id;
|
|
||||||
setCurrentWorkflowId(item.item.id);
|
|
||||||
if (!selectedThreadId) {
|
|
||||||
setSelectedThreadId(item.item.id);
|
|
||||||
}
|
|
||||||
// Check if workflow status is stopped
|
|
||||||
if (item.item.status === 'stopped') {
|
|
||||||
console.log('Workflow status is stopped');
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
// Refresh threads when workflow data arrives (e.g. name update from background)
|
|
||||||
if (item.item?.name) {
|
|
||||||
refreshThreads();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle messages
|
|
||||||
if (item.type === 'message' && item.item) {
|
|
||||||
const message = item.item as Message;
|
|
||||||
|
|
||||||
// Extract conversation/workflow ID from message (supports workflowId and conversationId)
|
|
||||||
const extractedWorkflowId = getConversationId(message);
|
|
||||||
if (extractedWorkflowId) {
|
|
||||||
// Update local variable and state if not already set
|
|
||||||
if (!newWorkflowId) {
|
|
||||||
newWorkflowId = extractedWorkflowId;
|
|
||||||
console.log('Extracting workflowId from message:', extractedWorkflowId);
|
|
||||||
}
|
|
||||||
// Always update state to ensure we have the latest workflowId
|
|
||||||
setCurrentWorkflowId(prev => {
|
|
||||||
if (!prev) {
|
|
||||||
console.log('Setting currentWorkflowId from message:', extractedWorkflowId);
|
|
||||||
return extractedWorkflowId;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
if (!selectedThreadId) {
|
|
||||||
setSelectedThreadId(extractedWorkflowId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setMessages(prev => {
|
|
||||||
// Check if message already exists by ID
|
|
||||||
if (prev.some(m => m.id === message.id)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backend sends the "first" message with the transformed/normalized user prompt
|
|
||||||
// Replace the temporary optimistic message with it
|
|
||||||
if (message.status === 'first') {
|
|
||||||
return prev.map(m =>
|
|
||||||
m.id === tempUserMessageId ? message : m
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final assistant message: replace streaming placeholder if we have one
|
|
||||||
if (message.role === 'assistant') {
|
|
||||||
const streamingIdx = prev.findIndex(m => m.id?.startsWith('streaming-'));
|
|
||||||
if (streamingIdx >= 0) {
|
|
||||||
const before = prev.slice(0, streamingIdx);
|
|
||||||
const after = prev.slice(streamingIdx + 1);
|
|
||||||
return [...before, message, ...after];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other messages, check for duplicates by role and content (more lenient check)
|
|
||||||
const isDuplicate = prev.some(m => {
|
|
||||||
// Exact ID match
|
|
||||||
if (m.id === message.id) return true;
|
|
||||||
// For same role and content, check if it's a duplicate
|
|
||||||
if (m.role === message.role && m.message === message.message) {
|
|
||||||
// If it's a user message, it's definitely a duplicate
|
|
||||||
if (message.role === 'user') return true;
|
|
||||||
// For assistant messages, check if timestamps are very close (within 1 second)
|
|
||||||
if (m.publishedAt && message.publishedAt) {
|
|
||||||
return Math.abs(m.publishedAt - message.publishedAt) < 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDuplicate) return prev;
|
|
||||||
return [...prev, message];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(err: Error) => {
|
|
||||||
console.error('Stream error:', err);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(err.message || 'Fehler beim Senden der Nachricht');
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setIsStreaming(false);
|
|
||||||
setStreamingStatus(null); // Clear status on completion
|
|
||||||
// Refresh threads to get updated list
|
|
||||||
refreshThreads();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refresh threads after completion
|
|
||||||
if (newWorkflowId) {
|
|
||||||
await refreshThreads();
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error sending message:', err);
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(err.message || 'Fehler beim Senden der Nachricht');
|
|
||||||
setIsStreaming(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [currentWorkflowId, selectedThreadId, isStreaming, instanceId, refreshThreads]);
|
|
||||||
|
|
||||||
// Stop streaming
|
|
||||||
const stopStreaming = useCallback(async () => {
|
|
||||||
if (!instanceId) {
|
|
||||||
console.warn('Cannot stop: missing instanceId', { instanceId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isStreaming) {
|
|
||||||
console.warn('Cannot stop: not currently streaming');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Immediately reset UI state for instant feedback
|
|
||||||
setIsStreaming(false);
|
|
||||||
console.log('UI reset immediately after stop button click');
|
|
||||||
|
|
||||||
// Try to get workflowId from currentWorkflowId, or from the latest message
|
|
||||||
let workflowIdToStop = currentWorkflowId;
|
|
||||||
if (!workflowIdToStop && messages.length > 0) {
|
|
||||||
// Try to extract workflowId from the latest message (supports workflowId and conversationId)
|
|
||||||
const latestMessage = messages[messages.length - 1];
|
|
||||||
workflowIdToStop = getConversationId(latestMessage) ?? null;
|
|
||||||
if (workflowIdToStop) {
|
|
||||||
console.log('Extracted workflowId from latest message:', workflowIdToStop);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workflowIdToStop) {
|
|
||||||
console.warn('Cannot stop: missing workflowId, but UI already reset', {
|
|
||||||
currentWorkflowId,
|
|
||||||
messagesCount: messages.length,
|
|
||||||
latestMessage: messages.length > 0 ? messages[messages.length - 1] : null
|
|
||||||
});
|
|
||||||
// UI already reset above, just return
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send stop request to backend (fire and forget - UI already reset)
|
|
||||||
try {
|
|
||||||
console.log('Sending stop request to backend for workflow:', workflowIdToStop);
|
|
||||||
// Don't await - let it run in background, UI is already reset
|
|
||||||
stopChatbotApi(request, instanceId, workflowIdToStop).catch((err: any) => {
|
|
||||||
console.error('Error stopping stream on backend (non-blocking):', err);
|
|
||||||
// Optionally show a non-intrusive error notification
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
// Don't reset isStreaming again as it's already false
|
|
||||||
// Just log the error
|
|
||||||
console.warn('Backend stop request failed, but UI was already reset');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error initiating stop request:', err);
|
|
||||||
// UI already reset, so just log the error
|
|
||||||
}
|
|
||||||
}, [currentWorkflowId, isStreaming, instanceId, request, messages]);
|
|
||||||
|
|
||||||
// Delete thread
|
|
||||||
const deleteThread = useCallback(async (workflowId: string) => {
|
|
||||||
if (!instanceId) return;
|
|
||||||
|
|
||||||
// Optimistic UI update - remove thread immediately
|
|
||||||
const previousThreads = threads;
|
|
||||||
setThreads(prev => prev.filter(t => t.id !== workflowId));
|
|
||||||
|
|
||||||
// If deleted thread was selected, clear selection immediately
|
|
||||||
if (selectedThreadId === workflowId) {
|
|
||||||
createNewThread();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteChatbotWorkflowApi(request, instanceId, workflowId);
|
|
||||||
|
|
||||||
// Refresh threads list to sync with server
|
|
||||||
await refreshThreads();
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error deleting thread:', err);
|
|
||||||
// Restore threads on error
|
|
||||||
setThreads(previousThreads);
|
|
||||||
setError(err.message || 'Fehler beim Löschen der Konversation');
|
|
||||||
}
|
|
||||||
}, [request, instanceId, selectedThreadId, threads, createNewThread, refreshThreads]);
|
|
||||||
|
|
||||||
// Initial load
|
|
||||||
useEffect(() => {
|
|
||||||
if (instanceId) {
|
|
||||||
refreshThreads();
|
|
||||||
}
|
|
||||||
}, [instanceId, refreshThreads]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
threads,
|
|
||||||
selectedThreadId,
|
|
||||||
loadingThreads,
|
|
||||||
error,
|
|
||||||
messages,
|
|
||||||
loadingMessages,
|
|
||||||
currentWorkflowId,
|
|
||||||
isStreaming,
|
|
||||||
streamingStatus,
|
|
||||||
selectThread,
|
|
||||||
createNewThread,
|
|
||||||
sendMessage,
|
|
||||||
stopStreaming,
|
|
||||||
deleteThread,
|
|
||||||
refreshThreads,
|
|
||||||
inputValue,
|
|
||||||
setInputValue
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook factory for use in GenericPageData inputFormConfig
|
|
||||||
*/
|
|
||||||
export function createChatbotHook() {
|
|
||||||
return () => {
|
|
||||||
const chatbot = useChatbot();
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages: chatbot.messages,
|
|
||||||
loading: chatbot.loadingMessages || chatbot.isStreaming,
|
|
||||||
error: chatbot.error,
|
|
||||||
data: [],
|
|
||||||
inputValue: chatbot.inputValue,
|
|
||||||
onInputChange: chatbot.setInputValue,
|
|
||||||
handleSubmit: async () => {
|
|
||||||
await chatbot.sendMessage(chatbot.inputValue);
|
|
||||||
},
|
|
||||||
isSubmitting: chatbot.isStreaming,
|
|
||||||
stopAction: chatbot.stopStreaming,
|
|
||||||
canStop: chatbot.isStreaming
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -127,43 +127,11 @@ export function useCanViewFeatureView(viewCode: string): boolean {
|
||||||
const { instance, featureCode } = useCurrentInstance();
|
const { instance, featureCode } = useCurrentInstance();
|
||||||
|
|
||||||
if (!instance?.permissions?.views) {
|
if (!instance?.permissions?.views) {
|
||||||
// DEBUG: Log for chatbot
|
|
||||||
if (featureCode === 'chatbot') {
|
|
||||||
console.log('🔍 [DEBUG] useCanViewFeatureView: No views permissions', {
|
|
||||||
viewCode,
|
|
||||||
featureCode,
|
|
||||||
instanceId: instance?.id,
|
|
||||||
hasPermissions: !!instance?.permissions,
|
|
||||||
hasViews: !!instance?.permissions?.views,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const views = instance.permissions.views;
|
const views = instance.permissions.views;
|
||||||
|
|
||||||
// DEBUG: Log for chatbot
|
|
||||||
if (featureCode === 'chatbot') {
|
|
||||||
const parts = viewCode.split('-');
|
|
||||||
const viewName = parts.length >= 2 ? parts.slice(1).join('-') : '';
|
|
||||||
const fullObjectKey = `ui.feature.${featureCode}.${viewName}`;
|
|
||||||
|
|
||||||
console.log('🔍 [DEBUG] useCanViewFeatureView: Checking permissions', {
|
|
||||||
viewCode,
|
|
||||||
featureCode,
|
|
||||||
viewName,
|
|
||||||
fullObjectKey,
|
|
||||||
instanceId: instance.id,
|
|
||||||
viewKeys: Object.keys(views),
|
|
||||||
hasWildcard: !!views["_all"],
|
|
||||||
hasLegacyView: !!views[viewCode],
|
|
||||||
hasFullObjectKey: !!views[fullObjectKey],
|
|
||||||
wildcardValue: views["_all"],
|
|
||||||
legacyValue: views[viewCode],
|
|
||||||
fullObjectKeyValue: views[fullObjectKey],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for wildcard "_all" permission first (item=None in backend = all views)
|
// Check for wildcard "_all" permission first (item=None in backend = all views)
|
||||||
if (views["_all"]) {
|
if (views["_all"]) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
14
src/main.tsx
14
src/main.tsx
|
|
@ -7,6 +7,20 @@ import './index.css'
|
||||||
import './styles/themes/light.css'
|
import './styles/themes/light.css'
|
||||||
import './styles/buttons.css'
|
import './styles/buttons.css'
|
||||||
|
|
||||||
|
// Browser extensions (e.g. password managers, ad blockers) inject content scripts
|
||||||
|
// whose chrome.runtime message listeners reject with this exact message when the
|
||||||
|
// extension's background context goes away. It is not an app error and cannot be
|
||||||
|
// fixed from the page — we only silence this one known-benign rejection so it does
|
||||||
|
// not clutter the console. Any other rejection is left untouched.
|
||||||
|
const _EXTENSION_NOISE = 'message channel closed before a response was received';
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const reason = event.reason;
|
||||||
|
const message = typeof reason === 'string' ? reason : reason?.message;
|
||||||
|
if (typeof message === 'string' && message.includes(_EXTENSION_NOISE)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,6 @@ import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
|
||||||
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
|
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
|
||||||
import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
|
import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
|
||||||
|
|
||||||
// Chatbot Views
|
|
||||||
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
|
||||||
|
|
||||||
// RealEstate Views
|
// RealEstate Views
|
||||||
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
|
|
@ -86,16 +83,6 @@ const ChatworkflowFiles: React.FC = () => {
|
||||||
return <PlaceholderView title={t('Dateien')} description={t('Workflow-Dateien')} />;
|
return <PlaceholderView title={t('Dateien')} description={t('Workflow-Dateien')} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chatbot Views
|
|
||||||
// ChatbotConversationsView is imported above
|
|
||||||
|
|
||||||
const ChatbotSettings: React.FC = () => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
return (
|
|
||||||
<PlaceholderView title={t('Chatbot-Einstellungen')} description={t('Konfiguration des Chatbots')} />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generic/Fallback
|
// Generic/Fallback
|
||||||
const NotFound: React.FC = () => {
|
const NotFound: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -138,10 +125,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
runs: ChatworkflowRuns,
|
runs: ChatworkflowRuns,
|
||||||
files: ChatworkflowFiles,
|
files: ChatworkflowFiles,
|
||||||
},
|
},
|
||||||
chatbot: {
|
|
||||||
conversations: ChatbotConversationsView,
|
|
||||||
settings: ChatbotSettings,
|
|
||||||
},
|
|
||||||
realestate: {
|
realestate: {
|
||||||
dashboard: RealEstatePekView,
|
dashboard: RealEstatePekView,
|
||||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
|
|
@ -197,25 +180,6 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
const canView = useCanViewFeatureView(viewCode);
|
const canView = useCanViewFeatureView(viewCode);
|
||||||
|
|
||||||
// DEBUG: Log permission check for chatbot
|
|
||||||
if (featureCode === 'chatbot') {
|
|
||||||
console.log('🔍 [DEBUG] FeatureView Permission Check:', {
|
|
||||||
featureCode,
|
|
||||||
view,
|
|
||||||
viewCode,
|
|
||||||
instanceId: instance?.id,
|
|
||||||
instanceLabel: instance?.instanceLabel,
|
|
||||||
isValid,
|
|
||||||
canView,
|
|
||||||
permissions: instance?.permissions,
|
|
||||||
views: instance?.permissions?.views,
|
|
||||||
viewKeys: instance?.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
|
||||||
hasLegacyView: instance?.permissions?.views?.[viewCode],
|
|
||||||
hasFullObjectKey: instance?.permissions?.views?.[`ui.feature.${featureCode}.${view}`],
|
|
||||||
hasWildcard: instance?.permissions?.views?.['_all'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nicht valider Kontext
|
// Nicht valider Kontext
|
||||||
if (!isValid || !featureCode || !instance) {
|
if (!isValid || !featureCode || !instance) {
|
||||||
return <NotFound />;
|
return <NotFound />;
|
||||||
|
|
|
||||||
|
|
@ -801,13 +801,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================== */
|
/* ============================================== */
|
||||||
/* Chatbot Configuration Styles */
|
/* Feature Instance Configuration Styles */
|
||||||
/* ============================================== */
|
/* ============================================== */
|
||||||
|
|
||||||
.chatbotConfigSection {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.configSectionTitle {
|
.configSectionTitle {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -848,7 +844,7 @@
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multiselect styles for chatbot connectors */
|
/* Multiselect styles for connector selection */
|
||||||
.multiselectContainer {
|
.multiselectContainer {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { ChatbotConfigSection } from './ChatbotConfigSection';
|
|
||||||
import { TextField } from '../../components/UiComponents/TextField';
|
import { TextField } from '../../components/UiComponents/TextField';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
|
|
@ -56,13 +55,9 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState<string | null>(null);
|
const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState<string | null>(null);
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
// Chatbot configuration state
|
// Instance creation state
|
||||||
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
|
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
|
||||||
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
|
const [createLabel, setCreateLabel] = useState<string>(''); // Label field value
|
||||||
const [chatbotConnectors, setChatbotConnectors] = useState<string[]>(['preprocessor']); // Array for multiselect (database connectors only)
|
|
||||||
const [chatbotSystemPrompt, setChatbotSystemPrompt] = useState<string>('');
|
|
||||||
const [chatbotEnableWebResearch, setChatbotEnableWebResearch] = useState<boolean>(true); // Enable Tavily web research
|
|
||||||
const [chatbotAllowedProviders, setChatbotAllowedProviders] = useState<string[]>([]); // Allowed LLM providers (empty = all)
|
|
||||||
|
|
||||||
// Ref to track form data for featureCode detection
|
// Ref to track form data for featureCode detection
|
||||||
const formDataRef = useRef<Record<string, any>>({});
|
const formDataRef = useRef<Record<string, any>>({});
|
||||||
|
|
@ -137,53 +132,17 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build config for chatbot instances
|
|
||||||
let config: Record<string, any> | undefined = undefined;
|
|
||||||
if (createFeatureCode === 'chatbot') {
|
|
||||||
// Validate required fields
|
|
||||||
if (!chatbotSystemPrompt || chatbotSystemPrompt.trim() === '') {
|
|
||||||
showError(t('Fehler'), t('System Prompt ist erforderlich für Chatbot-Instanzen.'));
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null;
|
|
||||||
config = {
|
|
||||||
connector: chatbotConnectors.length > 0 ? {
|
|
||||||
types: chatbotConnectors,
|
|
||||||
type: primaryConnector,
|
|
||||||
customConnectorClass: null
|
|
||||||
} : undefined,
|
|
||||||
prompts: {
|
|
||||||
useCustomPrompts: true,
|
|
||||||
customAnalysisPrompt: chatbotSystemPrompt,
|
|
||||||
customFinalAnswerPrompt: chatbotSystemPrompt
|
|
||||||
},
|
|
||||||
behavior: {
|
|
||||||
maxQueries: 5,
|
|
||||||
enableWebResearch: chatbotEnableWebResearch,
|
|
||||||
enableRetryOnEmpty: true,
|
|
||||||
maxRetryAttempts: 2
|
|
||||||
},
|
|
||||||
allowedProviders: chatbotAllowedProviders
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createInstance(selectedMandateId, {
|
const result = await createInstance(selectedMandateId, {
|
||||||
featureCode: createFeatureCode,
|
featureCode: createFeatureCode,
|
||||||
label: createLabel,
|
label: createLabel,
|
||||||
enabled: data.enabled !== false,
|
enabled: data.enabled !== false,
|
||||||
copyTemplateRoles: data.copyTemplateRoles !== false,
|
copyTemplateRoles: data.copyTemplateRoles !== false,
|
||||||
config: config
|
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowCreateModal(false);
|
setShowCreateModal(false);
|
||||||
setCreateFeatureCode('');
|
setCreateFeatureCode('');
|
||||||
setCreateLabel('');
|
setCreateLabel('');
|
||||||
formDataRef.current = {};
|
formDataRef.current = {};
|
||||||
setChatbotConnectors(['preprocessor']);
|
|
||||||
setChatbotSystemPrompt('');
|
|
||||||
setChatbotEnableWebResearch(true);
|
|
||||||
setChatbotAllowedProviders([]);
|
|
||||||
fetchInstances(selectedMandateId);
|
fetchInstances(selectedMandateId);
|
||||||
loadFeatures(); // Refresh global navigation cache
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
|
showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel }));
|
||||||
|
|
@ -207,24 +166,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
// Handle edit click
|
// Handle edit click
|
||||||
const handleEditClick = (instance: FeatureInstance) => {
|
const handleEditClick = (instance: FeatureInstance) => {
|
||||||
setEditingInstance(instance);
|
setEditingInstance(instance);
|
||||||
// Load chatbot config if it's a chatbot instance
|
|
||||||
if (instance.featureCode === 'chatbot' && instance.config) {
|
|
||||||
const config = instance.config as any;
|
|
||||||
// Support both new array format and legacy single type format
|
|
||||||
// Filter out 'websearch' if it exists (legacy)
|
|
||||||
const connectorTypes = (config?.connector?.types || (config?.connector?.type ? [config.connector.type] : ['preprocessor']))
|
|
||||||
.filter((c: string) => c !== 'websearch'); // Remove websearch from connectors
|
|
||||||
setChatbotConnectors(connectorTypes);
|
|
||||||
setChatbotSystemPrompt(config?.prompts?.customAnalysisPrompt || config?.prompts?.customFinalAnswerPrompt || '');
|
|
||||||
setChatbotEnableWebResearch(config?.behavior?.enableWebResearch !== false);
|
|
||||||
setChatbotAllowedProviders(Array.isArray(config?.allowedProviders) ? config.allowedProviders
|
|
||||||
: Array.isArray(config?.model?.allowedProviders) ? config.model.allowedProviders : []);
|
|
||||||
} else {
|
|
||||||
setChatbotConnectors([]);
|
|
||||||
setChatbotSystemPrompt('');
|
|
||||||
setChatbotEnableWebResearch(true);
|
|
||||||
setChatbotAllowedProviders([]);
|
|
||||||
}
|
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -233,51 +174,13 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
if (!selectedMandateId || !editingInstance) return;
|
if (!selectedMandateId || !editingInstance) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
// Build config for chatbot instances
|
|
||||||
let config: Record<string, any> | undefined = undefined;
|
|
||||||
if (editingInstance.featureCode === 'chatbot') {
|
|
||||||
// Validate required fields
|
|
||||||
if (!chatbotSystemPrompt || chatbotSystemPrompt.trim() === '') {
|
|
||||||
showError(t('Fehler'), t('System Prompt ist erforderlich für Chatbot-Instanzen.'));
|
|
||||||
setIsSubmitting(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const existingConfig = editingInstance.config as any || {};
|
|
||||||
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null;
|
|
||||||
config = {
|
|
||||||
...existingConfig,
|
|
||||||
connector: chatbotConnectors.length > 0 ? {
|
|
||||||
types: chatbotConnectors,
|
|
||||||
type: primaryConnector,
|
|
||||||
customConnectorClass: existingConfig.connector?.customConnectorClass || null
|
|
||||||
} : undefined,
|
|
||||||
prompts: {
|
|
||||||
useCustomPrompts: true, // Always true since system prompt is required
|
|
||||||
customAnalysisPrompt: chatbotSystemPrompt,
|
|
||||||
customFinalAnswerPrompt: chatbotSystemPrompt
|
|
||||||
},
|
|
||||||
behavior: {
|
|
||||||
...(existingConfig.behavior || {}),
|
|
||||||
maxQueries: existingConfig.behavior?.maxQueries || 5,
|
|
||||||
enableWebResearch: chatbotEnableWebResearch,
|
|
||||||
enableRetryOnEmpty: existingConfig.behavior?.enableRetryOnEmpty !== false,
|
|
||||||
maxRetryAttempts: existingConfig.behavior?.maxRetryAttempts || 2
|
|
||||||
},
|
|
||||||
allowedProviders: chatbotAllowedProviders
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateInstance(selectedMandateId, editingInstance.id, {
|
const result = await updateInstance(selectedMandateId, editingInstance.id, {
|
||||||
label: data.label,
|
label: data.label,
|
||||||
enabled: data.enabled,
|
enabled: data.enabled,
|
||||||
config: config
|
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingInstance(null);
|
setEditingInstance(null);
|
||||||
setChatbotConnectors(['preprocessor']);
|
|
||||||
setChatbotSystemPrompt('');
|
|
||||||
setChatbotAllowedProviders([]);
|
|
||||||
fetchInstances(selectedMandateId);
|
fetchInstances(selectedMandateId);
|
||||||
loadFeatures(); // Refresh global navigation cache
|
loadFeatures(); // Refresh global navigation cache
|
||||||
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
|
showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label }));
|
||||||
|
|
@ -562,10 +465,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCreateFeatureCode(f.code);
|
setCreateFeatureCode(f.code);
|
||||||
setChatbotConnectors(['preprocessor']);
|
|
||||||
setChatbotSystemPrompt('');
|
|
||||||
setChatbotEnableWebResearch(true);
|
|
||||||
setChatbotAllowedProviders([]);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{f.label || f.code}
|
{f.label || f.code}
|
||||||
|
|
@ -574,13 +473,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chatbot Configuration Title - Show when chatbot is selected */}
|
|
||||||
{createFeatureCode === 'chatbot' && (
|
|
||||||
<h3 className={styles.configSectionTitle} style={{ marginTop: '1.5rem', marginBottom: '1.5rem' }}>
|
|
||||||
{t('Chatbot-Konfiguration')}
|
|
||||||
</h3>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Label Field - Always shown after title */}
|
{/* Label Field - Always shown after title */}
|
||||||
{createFeatureCode && (
|
{createFeatureCode && (
|
||||||
<div className={styles.configField} style={{ marginBottom: '1.5rem' }}>
|
<div className={styles.configField} style={{ marginBottom: '1.5rem' }}>
|
||||||
|
|
@ -599,20 +491,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chatbot Configuration Section - Show when chatbot is selected */}
|
|
||||||
{createFeatureCode === 'chatbot' && (
|
|
||||||
<ChatbotConfigSection
|
|
||||||
connectors={chatbotConnectors}
|
|
||||||
systemPrompt={chatbotSystemPrompt}
|
|
||||||
enableWebResearch={chatbotEnableWebResearch}
|
|
||||||
allowedProviders={chatbotAllowedProviders}
|
|
||||||
onConnectorsChange={setChatbotConnectors}
|
|
||||||
onSystemPromptChange={setChatbotSystemPrompt}
|
|
||||||
onEnableWebResearchChange={setChatbotEnableWebResearch}
|
|
||||||
onAllowedProvidersChange={setChatbotAllowedProviders}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main Form - Only show if featureCode is selected */}
|
{/* Main Form - Only show if featureCode is selected */}
|
||||||
{createFeatureCode && (
|
{createFeatureCode && (
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
|
@ -625,10 +503,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
setCreateFeatureCode('');
|
setCreateFeatureCode('');
|
||||||
setCreateLabel('');
|
setCreateLabel('');
|
||||||
formDataRef.current = {};
|
formDataRef.current = {};
|
||||||
setChatbotConnectors(['preprocessor']);
|
|
||||||
setChatbotSystemPrompt('');
|
|
||||||
setChatbotEnableWebResearch(true);
|
|
||||||
setChatbotAllowedProviders([]);
|
|
||||||
}}
|
}}
|
||||||
submitButtonText={t('Erstellen')}
|
submitButtonText={t('Erstellen')}
|
||||||
cancelButtonText={t('Abbrechen')}
|
cancelButtonText={t('Abbrechen')}
|
||||||
|
|
@ -679,28 +553,10 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingInstance(null);
|
setEditingInstance(null);
|
||||||
setChatbotConnectors(['preprocessor']);
|
|
||||||
setChatbotSystemPrompt('');
|
|
||||||
setChatbotEnableWebResearch(true);
|
|
||||||
setChatbotAllowedProviders([]);
|
|
||||||
}}
|
}}
|
||||||
submitButtonText={t('Speichern')}
|
submitButtonText={t('Speichern')}
|
||||||
cancelButtonText={t('Abbrechen')}
|
cancelButtonText={t('Abbrechen')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Chatbot Configuration Section */}
|
|
||||||
{editingInstance?.featureCode === 'chatbot' && (
|
|
||||||
<ChatbotConfigSection
|
|
||||||
connectors={chatbotConnectors}
|
|
||||||
systemPrompt={chatbotSystemPrompt}
|
|
||||||
enableWebResearch={chatbotEnableWebResearch}
|
|
||||||
allowedProviders={chatbotAllowedProviders}
|
|
||||||
onConnectorsChange={setChatbotConnectors}
|
|
||||||
onSystemPromptChange={setChatbotSystemPrompt}
|
|
||||||
onEnableWebResearchChange={setChatbotEnableWebResearch}
|
|
||||||
onAllowedProvidersChange={setChatbotAllowedProviders}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
/**
|
|
||||||
* ChatbotConfigSection Component
|
|
||||||
*
|
|
||||||
* Displays chatbot-specific configuration fields (connector, system prompt)
|
|
||||||
* Only shown when featureCode is "chatbot"
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { TextField } from '../../components/UiComponents/TextField';
|
|
||||||
import { useBilling } from '../../hooks/useBilling';
|
|
||||||
import styles from './Admin.module.css';
|
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
||||||
|
|
||||||
const PROVIDER_LABELS: Record<string, string> = {
|
|
||||||
anthropic: 'Anthropic (Claude)',
|
|
||||||
openai: 'OpenAI (GPT)',
|
|
||||||
perplexity: 'Perplexity',
|
|
||||||
tavily: 'Tavily (Web Search)',
|
|
||||||
privatellm: 'Private LLM',
|
|
||||||
internal: 'Internal',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface ChatbotConfig {
|
|
||||||
connector: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatbotConfigSectionProps {
|
|
||||||
connectors: string[]; // Array of selected connector types (database connectors only)
|
|
||||||
systemPrompt: string;
|
|
||||||
enableWebResearch: boolean; // Enable Tavily web research
|
|
||||||
allowedProviders: string[]; // Selected LLM providers (empty = all allowed)
|
|
||||||
onConnectorsChange: (connectors: string[]) => void;
|
|
||||||
onSystemPromptChange: (prompt: string) => void;
|
|
||||||
onEnableWebResearchChange: (enabled: boolean) => void;
|
|
||||||
onAllowedProvidersChange: (providers: string[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({ connectors,
|
|
||||||
systemPrompt,
|
|
||||||
enableWebResearch,
|
|
||||||
allowedProviders,
|
|
||||||
onConnectorsChange,
|
|
||||||
onSystemPromptChange,
|
|
||||||
onEnableWebResearchChange,
|
|
||||||
onAllowedProvidersChange,
|
|
||||||
}) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const { allowedProviders: availableProviders, loadAllowedProviders, loading: providersLoading } = useBilling();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (availableProviders.length === 0 && !providersLoading) {
|
|
||||||
loadAllowedProviders();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const availableConnectors = [
|
|
||||||
{ id: 'preprocessor', label: t('Althaus Preprocessor'), value: 'preprocessor' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleConnectorToggle = (connectorValue: string) => {
|
|
||||||
if (connectors.includes(connectorValue)) {
|
|
||||||
onConnectorsChange(connectors.filter(c => c !== connectorValue));
|
|
||||||
} else {
|
|
||||||
onConnectorsChange([...connectors, connectorValue]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProviderToggle = (provider: string) => {
|
|
||||||
if (allowedProviders.includes(provider)) {
|
|
||||||
onAllowedProvidersChange(allowedProviders.filter(p => p !== provider));
|
|
||||||
} else {
|
|
||||||
onAllowedProvidersChange([...allowedProviders, provider]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.chatbotConfigSection}>
|
|
||||||
<div className={styles.configField}>
|
|
||||||
<label className={styles.configLabel}>{t('Connector')}:</label>
|
|
||||||
<div className={styles.multiselectContainer}>
|
|
||||||
{availableConnectors.map(connector => {
|
|
||||||
const isSelected = connectors.includes(connector.value);
|
|
||||||
return (
|
|
||||||
<label key={connector.id} className={styles.multiselectOption}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => handleConnectorToggle(connector.value)}
|
|
||||||
className={styles.multiselectCheckbox}
|
|
||||||
/>
|
|
||||||
<span className={styles.multiselectLabel}>{connector.label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{connectors.length === 0 && (
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
|
||||||
{t('Ohne Connector werden keine SQL-Abfragen unterstützt.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.configField}>
|
|
||||||
<label className={styles.configLabel} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={enableWebResearch}
|
|
||||||
onChange={(e) => onEnableWebResearchChange(e.target.checked)}
|
|
||||||
className={styles.multiselectCheckbox}
|
|
||||||
/>
|
|
||||||
<span>{t('Web Research aktivieren (Tavily)')}</span>
|
|
||||||
</label>
|
|
||||||
<p className={styles.configHelpText}>
|
|
||||||
{t('Wenn aktiviert, führt der Chatbot zusätzlich Web-Recherchen mit Tavily durch, um aktuelle Informationen aus dem Internet zu finden.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.configField}>
|
|
||||||
<label className={styles.configLabel}>{t('LLM-Anbieter')}:</label>
|
|
||||||
<div className={styles.multiselectContainer}>
|
|
||||||
{providersLoading ? (
|
|
||||||
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{t('Anbieter laden')}</span>
|
|
||||||
) : (
|
|
||||||
availableProviders.map(provider => (
|
|
||||||
<label key={provider} className={styles.multiselectOption}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allowedProviders.includes(provider)}
|
|
||||||
onChange={() => handleProviderToggle(provider)}
|
|
||||||
className={styles.multiselectCheckbox}
|
|
||||||
/>
|
|
||||||
<span className={styles.multiselectLabel}>
|
|
||||||
{PROVIDER_LABELS[provider] || provider}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{allowedProviders.length === 0 && !providersLoading && (
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
|
|
||||||
{t('Keine Einschränkung – alle verfügbaren Anbieter werden verwendet.')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.configField}>
|
|
||||||
<label className={styles.configLabel}>
|
|
||||||
{t('System Prompt')}: <span style={{ color: 'var(--error-color)' }}>*</span>
|
|
||||||
</label>
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
value={systemPrompt}
|
|
||||||
onChange={onSystemPromptChange}
|
|
||||||
placeholder={t('Benutzerdefinierter Systemprompt für den Chatbot')}
|
|
||||||
className={styles.configTextArea}
|
|
||||||
size="md"
|
|
||||||
rows={6}
|
|
||||||
required={true}
|
|
||||||
/>
|
|
||||||
<p className={styles.configHelpText}>
|
|
||||||
{t('Dieser Prompt wird für Analyse und Antwort-Generierung verwendet (erforderlich).')}{' '}
|
|
||||||
{t('Platzhalter')}: {'{userPrompt}'}, {'{context}'}, {'{db_results_part}'}, {'{web_results_part}'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
/**
|
|
||||||
* SttBenchmarkPage — Compare STT v1 (latest_long) vs v2 (Chirp 2).
|
|
||||||
* SysAdmin only. Upload audio, run both engines, compare results.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { FaMicrophone, FaUpload, FaPlay, FaStop, FaSpinner } from 'react-icons/fa';
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
|
||||||
import styles from '../admin/Admin.module.css';
|
|
||||||
|
|
||||||
interface ModelOption { value: string; label: string }
|
|
||||||
interface BenchmarkResult {
|
|
||||||
api: string;
|
|
||||||
model: string;
|
|
||||||
latencyMs: number;
|
|
||||||
results: { transcript: string; confidence: number; words: number }[];
|
|
||||||
resultCount: number;
|
|
||||||
location?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
interface BenchmarkResponse {
|
|
||||||
filename: string;
|
|
||||||
fileSizeBytes: number;
|
|
||||||
language: string;
|
|
||||||
v1: BenchmarkResult | { error: string };
|
|
||||||
v2: BenchmarkResult | { error: string };
|
|
||||||
}
|
|
||||||
interface ModelsResponse {
|
|
||||||
v1Models: ModelOption[];
|
|
||||||
v2Models: ModelOption[];
|
|
||||||
locations: ModelOption[];
|
|
||||||
languages: ModelOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SttBenchmarkPage: React.FC = () => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const { request } = useApiRequest();
|
|
||||||
|
|
||||||
const [models, setModels] = useState<ModelsResponse | null>(null);
|
|
||||||
const [language, setLanguage] = useState('de-DE');
|
|
||||||
const [v1Model, setV1Model] = useState('latest_long');
|
|
||||||
const [v2Model, setV2Model] = useState('chirp_2');
|
|
||||||
const [v2Location, setV2Location] = useState('europe-west4');
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
const [result, setResult] = useState<BenchmarkResponse | null>(null);
|
|
||||||
|
|
||||||
const [recording, setRecording] = useState(false);
|
|
||||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
|
||||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
request({ url: '/api/admin/stt-benchmark/models', method: 'get' })
|
|
||||||
.then((data: any) => setModels(data))
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _startRecording = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
|
|
||||||
chunksRef.current = [];
|
|
||||||
recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
|
||||||
recorder.onstop = () => {
|
|
||||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
|
||||||
setAudioBlob(blob);
|
|
||||||
setAudioUrl(URL.createObjectURL(blob));
|
|
||||||
stream.getTracks().forEach(t => t.stop());
|
|
||||||
};
|
|
||||||
mediaRecorderRef.current = recorder;
|
|
||||||
recorder.start();
|
|
||||||
setRecording(true);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Microphone access denied', err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _stopRecording = useCallback(() => {
|
|
||||||
mediaRecorderRef.current?.stop();
|
|
||||||
setRecording(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setAudioBlob(file);
|
|
||||||
setAudioUrl(URL.createObjectURL(file));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const _runBenchmark = useCallback(async () => {
|
|
||||||
if (!audioBlob) return;
|
|
||||||
setRunning(true);
|
|
||||||
setResult(null);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
const filename = audioBlob instanceof File ? audioBlob.name : 'recording.webm';
|
|
||||||
formData.append('file', audioBlob, filename);
|
|
||||||
formData.append('language', language);
|
|
||||||
formData.append('v1Model', v1Model);
|
|
||||||
formData.append('v2Model', v2Model);
|
|
||||||
formData.append('v2Location', v2Location);
|
|
||||||
|
|
||||||
const resp = await fetch('/api/admin/stt-benchmark/run', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data: BenchmarkResponse = await resp.json();
|
|
||||||
setResult(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Benchmark failed:', err);
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
}, [audioBlob, language, v1Model, v2Model, v2Location]);
|
|
||||||
|
|
||||||
const _renderResult = (label: string, r: BenchmarkResult | { error: string }) => {
|
|
||||||
if ('error' in r && r.error) {
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, padding: 16, border: '1px solid #e74c3c', borderRadius: 8, background: '#fdf2f2' }}>
|
|
||||||
<h3>{label}</h3>
|
|
||||||
<p style={{ color: '#e74c3c' }}>{r.error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const res = r as BenchmarkResult;
|
|
||||||
const topTranscript = res.results?.[0]?.transcript || '(no result)';
|
|
||||||
const topConfidence = res.results?.[0]?.confidence ?? 0;
|
|
||||||
return (
|
|
||||||
<div style={{ flex: 1, padding: 16, border: '1px solid #ddd', borderRadius: 8, background: '#fafafa' }}>
|
|
||||||
<h3 style={{ margin: '0 0 8px' }}>{label}</h3>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 12 }}>
|
|
||||||
<div><strong>{t('Modell')}:</strong> {res.model}</div>
|
|
||||||
<div><strong>{t('Latenz')}:</strong> {res.latencyMs} ms</div>
|
|
||||||
<div><strong>{t('Konfidenz')}:</strong> {(topConfidence * 100).toFixed(1)}%</div>
|
|
||||||
<div><strong>{t('Alternativen')}:</strong> {res.results?.length || 0}</div>
|
|
||||||
{res.location && <div><strong>{t('Region')}:</strong> {res.location}</div>}
|
|
||||||
</div>
|
|
||||||
<div style={{ background: '#fff', padding: 12, borderRadius: 6, border: '1px solid #eee', fontSize: 15 }}>
|
|
||||||
{topTranscript}
|
|
||||||
</div>
|
|
||||||
{res.results?.length > 1 && (
|
|
||||||
<details style={{ marginTop: 8 }}>
|
|
||||||
<summary style={{ cursor: 'pointer', fontSize: 13, color: '#666' }}>{t('Weitere Alternativen')}</summary>
|
|
||||||
{res.results.slice(1).map((alt, i) => (
|
|
||||||
<div key={i} style={{ marginTop: 4, fontSize: 13, color: '#888' }}>
|
|
||||||
[{(alt.confidence * 100).toFixed(1)}%] {alt.transcript}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</details>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.adminPage}>
|
|
||||||
<div className={styles.adminHeader}>
|
|
||||||
<h1><FaMicrophone style={{ marginRight: 8 }} /> {t('STT Benchmark')}</h1>
|
|
||||||
<p style={{ color: '#666', margin: '4px 0 0' }}>
|
|
||||||
{t('Vergleiche Speech-to-Text v1 (latest_long) mit v2 (Chirp 2). Lade eine Audio-Datei hoch oder nimm direkt auf.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', margin: '20px 0' }}>
|
|
||||||
<label>
|
|
||||||
<strong>{t('Sprache')}:</strong>
|
|
||||||
<select value={language} onChange={e => setLanguage(e.target.value)} style={{ marginLeft: 8 }}>
|
|
||||||
{(models?.languages || [{ value: 'de-DE', label: 'Deutsch' }]).map(l => (
|
|
||||||
<option key={l.value} value={l.value}>{l.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<strong>v1 {t('Modell')}:</strong>
|
|
||||||
<select value={v1Model} onChange={e => setV1Model(e.target.value)} style={{ marginLeft: 8 }}>
|
|
||||||
{(models?.v1Models || [{ value: 'latest_long', label: 'latest_long' }]).map(m => (
|
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<strong>v2 {t('Modell')}:</strong>
|
|
||||||
<select value={v2Model} onChange={e => setV2Model(e.target.value)} style={{ marginLeft: 8 }}>
|
|
||||||
{(models?.v2Models || [{ value: 'chirp_2', label: 'Chirp 2' }]).map(m => (
|
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<strong>{t('Region')} (v2):</strong>
|
|
||||||
<select value={v2Location} onChange={e => setV2Location(e.target.value)} style={{ marginLeft: 8 }}>
|
|
||||||
{(models?.locations || [{ value: 'europe-west4', label: 'Europe West' }]).map(l => (
|
|
||||||
<option key={l.value} value={l.value}>{l.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center', margin: '16px 0' }}>
|
|
||||||
{!recording ? (
|
|
||||||
<button onClick={_startRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#e74c3c', color: '#fff', border: 'none', borderRadius: 6 }}>
|
|
||||||
<FaMicrophone /> {t('Aufnehmen')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={_stopRecording} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#333', color: '#fff', border: 'none', borderRadius: 6, animation: 'pulse 1s infinite' }}>
|
|
||||||
<FaStop /> {t('Stoppen')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button onClick={() => fileInputRef.current?.click()} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px', cursor: 'pointer', background: '#3498db', color: '#fff', border: 'none', borderRadius: 6 }}>
|
|
||||||
<FaUpload /> {t('Datei hochladen')}
|
|
||||||
</button>
|
|
||||||
<input ref={fileInputRef} type="file" accept="audio/*" onChange={_handleFileSelect} style={{ display: 'none' }} />
|
|
||||||
|
|
||||||
{audioBlob && (
|
|
||||||
<>
|
|
||||||
<span style={{ color: '#666', fontSize: 13 }}>
|
|
||||||
{audioBlob instanceof File ? audioBlob.name : 'recording.webm'} ({(audioBlob.size / 1024).toFixed(0)} KB)
|
|
||||||
</span>
|
|
||||||
{audioUrl && <audio src={audioUrl} controls style={{ height: 32 }} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={_runBenchmark}
|
|
||||||
disabled={!audioBlob || running}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 24px', cursor: audioBlob && !running ? 'pointer' : 'not-allowed',
|
|
||||||
background: audioBlob && !running ? '#27ae60' : '#bdc3c7', color: '#fff', border: 'none', borderRadius: 6, fontSize: 15, fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{running ? <FaSpinner className="fa-spin" /> : <FaPlay />}
|
|
||||||
{running ? t('Benchmark laeuft...') : t('Benchmark starten')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<div style={{ marginTop: 24 }}>
|
|
||||||
<h2>{t('Ergebnis')}</h2>
|
|
||||||
<p style={{ fontSize: 13, color: '#888' }}>
|
|
||||||
{result.filename} ({(result.fileSizeBytes / 1024).toFixed(0)} KB) — {result.language}
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 12 }}>
|
|
||||||
{_renderResult(`v1 — ${(result.v1 as any).model || v1Model}`, result.v1)}
|
|
||||||
{_renderResult(`v2 — ${(result.v2 as any).model || v2Model}`, result.v2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SttBenchmarkPage;
|
|
||||||
|
|
@ -19,4 +19,3 @@ export { AdminLogsPage } from './AdminLogsPage';
|
||||||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||||
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
||||||
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
|
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
|
||||||
export { SttBenchmarkPage } from './SttBenchmarkPage';
|
|
||||||
|
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
/**
|
|
||||||
* ChatbotConversationsView
|
|
||||||
*
|
|
||||||
* Chatbot interface with chat history sidebar and messages view.
|
|
||||||
* Similar to trustee views but hardcoded for chatbot feature.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
|
||||||
import { useChatbot } from '../../../hooks/useChatbot';
|
|
||||||
import { useConfirm } from '../../../hooks/useConfirm';
|
|
||||||
import { TextField } from '../../../components/UiComponents/TextField';
|
|
||||||
import { Button } from '../../../components/UiComponents/Button';
|
|
||||||
import { AutoScroll } from '../../../components/UiComponents/AutoScroll';
|
|
||||||
import { ChatMessage } from '../../../components/UiComponents/Messages/ChatMessages/ChatMessage';
|
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
|
||||||
import { IoMdSend } from 'react-icons/io';
|
|
||||||
import { MdStop } from 'react-icons/md';
|
|
||||||
import { LuMessageSquare, LuTrash2 } from 'react-icons/lu';
|
|
||||||
import messagesStyles from '../../../components/UiComponents/Messages/Messages.module.css';
|
|
||||||
import styles from './ChatbotViews.module.css';
|
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
||||||
|
|
||||||
export const ChatbotConversationsView: React.FC = () => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
|
|
||||||
const {
|
|
||||||
threads,
|
|
||||||
selectedThreadId,
|
|
||||||
loadingThreads,
|
|
||||||
error,
|
|
||||||
messages,
|
|
||||||
loadingMessages,
|
|
||||||
isStreaming,
|
|
||||||
streamingStatus,
|
|
||||||
currentWorkflowId,
|
|
||||||
selectThread,
|
|
||||||
createNewThread,
|
|
||||||
sendMessage,
|
|
||||||
stopStreaming,
|
|
||||||
deleteThread,
|
|
||||||
refreshThreads,
|
|
||||||
inputValue,
|
|
||||||
setInputValue
|
|
||||||
} = useChatbot();
|
|
||||||
|
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!inputValue.trim() || isStreaming) return;
|
|
||||||
await sendMessage(inputValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
// Enter ohne Shift sendet die Nachricht
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!inputValue.trim() || isStreaming) return;
|
|
||||||
sendMessage(inputValue);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleStop = async () => {
|
|
||||||
console.log('Stop button clicked', {
|
|
||||||
isStreaming,
|
|
||||||
currentWorkflowId,
|
|
||||||
selectedThreadId,
|
|
||||||
hasMessages: messages.length > 0
|
|
||||||
});
|
|
||||||
if (isStreaming) {
|
|
||||||
console.log('Calling stopStreaming...');
|
|
||||||
try {
|
|
||||||
await stopStreaming();
|
|
||||||
console.log('stopStreaming completed');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in stopStreaming:', error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('Stop button clicked but not streaming');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteThread = useCallback(async (e: React.MouseEvent, workflowId: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const ok = await confirm(t('Möchten Sie diese Konversation wirklich löschen?'), {
|
|
||||||
title: t('Konversation löschen'),
|
|
||||||
confirmLabel: t('Löschen'),
|
|
||||||
variant: 'danger',
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
setDeletingId(workflowId);
|
|
||||||
try {
|
|
||||||
await deleteThread(workflowId);
|
|
||||||
} finally {
|
|
||||||
setDeletingId(null);
|
|
||||||
}
|
|
||||||
}, [confirm, deleteThread, t]);
|
|
||||||
|
|
||||||
const formatDate = (timestamp?: number) => {
|
|
||||||
if (!timestamp) return '';
|
|
||||||
// Convert Unix timestamp (seconds) to milliseconds using the time utility logic
|
|
||||||
const milliseconds = timestamp * 1000;
|
|
||||||
const date = new Date(milliseconds);
|
|
||||||
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 t('Gerade eben');
|
|
||||||
if (diffMins < 60) return t('Vor {n} Min', { n: diffMins });
|
|
||||||
if (diffHours < 24) return t('Vor {n} Std', { n: diffHours });
|
|
||||||
if (diffDays < 7) return t('Vor {n} Tagen', { n: diffDays });
|
|
||||||
|
|
||||||
// For older dates, use the formatUnixTimestamp utility for consistent formatting
|
|
||||||
const { time } = formatUnixTimestamp(timestamp, 'de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric'
|
|
||||||
});
|
|
||||||
return time;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getThreadTitle = (thread: any) => {
|
|
||||||
if (thread.name) return thread.name;
|
|
||||||
// Try to get first message content as title
|
|
||||||
return t('Neue Konversation');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.chatbotView}>
|
|
||||||
{/* Chat History Sidebar */}
|
|
||||||
<aside className={styles.chatHistory} aria-label={t('Konversationen')}>
|
|
||||||
<div className={styles.chatHistoryHeader} style={{ justifyContent: 'flex-end' }}>
|
|
||||||
<button
|
|
||||||
className={styles.newChatButton}
|
|
||||||
onClick={createNewThread}
|
|
||||||
title={t('Neues Gespräch')}
|
|
||||||
>
|
|
||||||
<LuMessageSquare /> {t('Neu')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingThreads ? (
|
|
||||||
<div className={styles.loading}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>{t('Konversationen laden')}</span>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className={styles.error}>
|
|
||||||
<p>{error}</p>
|
|
||||||
<button className={styles.retryButton} onClick={refreshThreads}>
|
|
||||||
{t('Erneut versuchen')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : threads.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
|
||||||
<LuMessageSquare className={styles.emptyIcon} />
|
|
||||||
<p>{t('Noch keine Gespräche')}</p>
|
|
||||||
<p className={styles.emptyHint}>{t('Starten Sie ein neues Gespräch, um loszulegen.')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.threadList}>
|
|
||||||
{threads.map((thread) => (
|
|
||||||
<div
|
|
||||||
key={thread.id}
|
|
||||||
className={`${styles.threadItem} ${selectedThreadId === thread.id ? styles.selected : ''}`}
|
|
||||||
onClick={() => selectThread(thread.id)}
|
|
||||||
>
|
|
||||||
<div className={styles.threadContent}>
|
|
||||||
<div className={styles.threadTitle}>{getThreadTitle(thread)}</div>
|
|
||||||
<div className={styles.threadMeta}>
|
|
||||||
{formatDate(thread.lastActivity || thread.startedAt)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className={styles.deleteButton}
|
|
||||||
onClick={(e) => handleDeleteThread(e, thread.id)}
|
|
||||||
disabled={deletingId === thread.id}
|
|
||||||
title={t('Löschen')}
|
|
||||||
>
|
|
||||||
{deletingId === thread.id ? (
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
) : (
|
|
||||||
<LuTrash2 />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Chat Area */}
|
|
||||||
<main className={styles.chatArea}>
|
|
||||||
{/* Messages Area */}
|
|
||||||
<div className={styles.messagesArea}>
|
|
||||||
{loadingMessages && messages.length === 0 ? (
|
|
||||||
<div className={styles.loading}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>{t('Nachrichten laden')}</span>
|
|
||||||
</div>
|
|
||||||
) : messages.length === 0 ? (
|
|
||||||
<div className={`${messagesStyles.messagesContainer} ${messagesStyles.emptyContainer}`}>
|
|
||||||
<div className={messagesStyles.emptyState}>
|
|
||||||
{t('Noch keine Nachrichten. Starte eine Konversation!')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AutoScroll
|
|
||||||
scrollDependency={messages.length + (isStreaming ? 1 : 0)}
|
|
||||||
>
|
|
||||||
<div className={messagesStyles.messagesContainer}>
|
|
||||||
{messages.map((message) => (
|
|
||||||
<ChatMessage
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
showDocuments={true}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{isStreaming && (
|
|
||||||
<div className={styles.typingIndicator}>
|
|
||||||
<div className={styles.typingBubble}>
|
|
||||||
{streamingStatus ? (
|
|
||||||
<div className={styles.streamingStatus}>
|
|
||||||
<div className={styles.statusSpinner} />
|
|
||||||
<span className={styles.statusText}>{streamingStatus}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className={styles.typingDots}>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AutoScroll>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Input Form */}
|
|
||||||
<form onSubmit={handleSubmit} className={styles.inputForm}>
|
|
||||||
<TextField
|
|
||||||
value={inputValue}
|
|
||||||
onChange={setInputValue}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={t('Nachricht eingeben')}
|
|
||||||
disabled={isStreaming}
|
|
||||||
className={styles.inputField}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
{isStreaming ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleStop}
|
|
||||||
variant="danger"
|
|
||||||
size="md"
|
|
||||||
icon={MdStop}
|
|
||||||
disabled={!isStreaming}
|
|
||||||
>
|
|
||||||
Stoppen
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!inputValue.trim() || isStreaming}
|
|
||||||
variant="primary"
|
|
||||||
size="md"
|
|
||||||
icon={IoMdSend}
|
|
||||||
>
|
|
||||||
Senden
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
<ConfirmDialog />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ChatbotConversationsView;
|
|
||||||
|
|
@ -1,467 +0,0 @@
|
||||||
/**
|
|
||||||
* Chatbot Views Shared Styles
|
|
||||||
*/
|
|
||||||
|
|
||||||
.chatbotView {
|
|
||||||
display: flex;
|
|
||||||
height: calc(100vh - 200px);
|
|
||||||
min-height: 600px;
|
|
||||||
gap: 1rem;
|
|
||||||
background: var(--bg-primary, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
* Chat History Sidebar
|
|
||||||
* ============================================================================= */
|
|
||||||
|
|
||||||
.chatHistory {
|
|
||||||
width: 300px;
|
|
||||||
min-width: 250px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--surface-color, #f8f9fa);
|
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatHistoryHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem;
|
|
||||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
background: var(--bg-primary, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatHistoryTitle {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary, #1a1a1a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.newChatButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--primary-color, #2563eb);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--primary-color, #2563eb);
|
|
||||||
color: white;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.newChatButton:hover {
|
|
||||||
background: var(--primary-hover, #1d4ed8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadList {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.75rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background: var(--bg-primary, #ffffff);
|
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadItem:hover {
|
|
||||||
background: var(--hover-bg, rgba(0, 0, 0, 0.02));
|
|
||||||
border-color: var(--primary-color, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadItem.selected {
|
|
||||||
background: var(--primary-light, #eff6ff);
|
|
||||||
border-color: var(--primary-color, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadContent {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadTitle {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary, #1a1a1a);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadMeta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.375rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.threadItem:hover .deleteButton {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton:hover {
|
|
||||||
background: var(--error-light, #fee2e2);
|
|
||||||
color: var(--error-color, #dc2626);
|
|
||||||
}
|
|
||||||
|
|
||||||
.deleteButton:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
* Chat Area
|
|
||||||
* ============================================================================= */
|
|
||||||
|
|
||||||
.chatArea {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: var(--bg-primary, #ffffff);
|
|
||||||
border: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagesArea {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: var(--bg-primary, #ffffff);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messagesWrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
* Typing Indicator (WhatsApp style)
|
|
||||||
* ============================================================================= */
|
|
||||||
|
|
||||||
.typingIndicator {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typingBubble {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background-color: var(--color-surface, #f0f0f0);
|
|
||||||
color: var(--color-text, #1a1a1a);
|
|
||||||
border-radius: 18px;
|
|
||||||
border-bottom-left-radius: 4px;
|
|
||||||
max-width: 65%;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.typingDots {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typingDots span {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: var(--color-gray, #999);
|
|
||||||
animation: typingBounce 1.4s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typingDots span:nth-child(1) {
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typingDots span:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.typingDots span:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes typingBounce {
|
|
||||||
0%, 60%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
* Streaming Status (with status message)
|
|
||||||
* ============================================================================= */
|
|
||||||
|
|
||||||
.streamingStatus {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusSpinner {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border: 2px solid var(--border-color, #e0e0e0);
|
|
||||||
border-top-color: var(--primary-color, #2563eb);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusText {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme support for streaming status */
|
|
||||||
:global(.dark-theme) .statusSpinner {
|
|
||||||
border-color: var(--border-dark, #444);
|
|
||||||
border-top-color: var(--primary-color, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .statusText {
|
|
||||||
color: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark theme support for typing indicator */
|
|
||||||
:global(.dark-theme) .typingBubble {
|
|
||||||
background-color: var(--surface-dark, #2a2a2a);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .typingDots span {
|
|
||||||
background-color: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputForm {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
|
||||||
background: var(--surface-color, #f8f9fa);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inputField {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
* Loading & Error States
|
|
||||||
* ============================================================================= */
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border: 3px solid var(--border-color, #e0e0e0);
|
|
||||||
border-top-color: var(--primary-color, #2563eb);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--error-color, #dc2626);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retryButton {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border: 1px solid var(--error-color, #dc2626);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--error-color, #dc2626);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retryButton:hover {
|
|
||||||
background: var(--error-light, #fee2e2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyIcon {
|
|
||||||
font-size: 3rem;
|
|
||||||
color: var(--text-tertiary, #999);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyState p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyHint {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-tertiary, #888);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================================
|
|
||||||
* Dark Theme
|
|
||||||
* ============================================================================= */
|
|
||||||
|
|
||||||
:global(.dark-theme) .chatbotView {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .chatHistory {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
border-color: var(--border-dark, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .chatHistoryHeader {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
border-bottom-color: var(--border-dark, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .chatHistoryTitle {
|
|
||||||
color: var(--text-primary-dark, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .threadItem {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
border-color: var(--border-dark, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .threadItem:hover {
|
|
||||||
background: var(--surface-dark, #2a2a2a);
|
|
||||||
border-color: var(--primary-color, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .threadItem.selected {
|
|
||||||
background: var(--primary-dark, #1e3a8a);
|
|
||||||
border-color: var(--primary-color, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .threadTitle {
|
|
||||||
color: var(--text-primary-dark, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .threadMeta {
|
|
||||||
color: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .deleteButton {
|
|
||||||
color: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .deleteButton:hover {
|
|
||||||
background: var(--error-dark, #450a0a);
|
|
||||||
color: var(--error-light, #fef2f2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .chatArea {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
border-color: var(--border-dark, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .messagesArea {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .inputForm {
|
|
||||||
background: var(--surface-dark, #1a1a1a);
|
|
||||||
border-top-color: var(--border-dark, #333);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .loading {
|
|
||||||
color: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .spinner {
|
|
||||||
border-color: var(--border-dark, #333);
|
|
||||||
border-top-color: var(--primary-color, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .error {
|
|
||||||
color: var(--error-light, #fef2f2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .retryButton {
|
|
||||||
border-color: var(--error-color, #dc2626);
|
|
||||||
color: var(--error-light, #fef2f2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .retryButton:hover {
|
|
||||||
background: var(--error-dark, #450a0a);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .emptyState {
|
|
||||||
color: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .emptyIcon {
|
|
||||||
color: var(--text-tertiary-dark, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .emptyHint {
|
|
||||||
color: var(--text-tertiary-dark, #666);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark-theme) .typingDots span {
|
|
||||||
background: var(--text-secondary-dark, #aaa);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
/**
|
|
||||||
* Chatbot Views Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { ChatbotConversationsView } from './ChatbotConversationsView';
|
|
||||||
|
|
@ -61,18 +61,20 @@ type RightTab = 'activity' | 'preview';
|
||||||
|
|
||||||
interface WorkspacePageProps {
|
interface WorkspacePageProps {
|
||||||
persistentInstanceId?: string;
|
persistentInstanceId?: string;
|
||||||
|
persistentMandateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId }) => {
|
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId, persistentMandateId }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { instance } = useCurrentInstance();
|
const { instance } = useCurrentInstance();
|
||||||
const instanceId = persistentInstanceId || instance?.id || '';
|
const instanceId = persistentInstanceId || instance?.id || '';
|
||||||
const workspace = useWorkspace(instanceId);
|
const workspace = useWorkspace(instanceId);
|
||||||
const fileOps = useFileOperations();
|
const fileOps = useFileOperations();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
|
const { mandateId: routeMandateId, featureCode, instanceId: routeInstanceId } = useParams<{
|
||||||
mandateId: string; featureCode: string; instanceId: string;
|
mandateId: string; featureCode: string; instanceId: string;
|
||||||
}>();
|
}>();
|
||||||
|
const mandateId = persistentMandateId || routeMandateId;
|
||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const _leftResize = _useResizable(280, 200, 800);
|
const _leftResize = _useResizable(280, 200, 800);
|
||||||
|
|
@ -350,7 +352,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
|
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
|
||||||
const _handleAttachDataSource = useCallback((dsId: string) => {
|
const _handleAttachDataSource = useCallback((dsId: string) => {
|
||||||
setPendingAttachDsId(dsId);
|
setPendingAttachDsId(dsId);
|
||||||
}, []);
|
workspace.refreshDataSources();
|
||||||
|
}, [workspace]);
|
||||||
|
|
||||||
const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
|
const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { startSseStream, SseEvent } from '../../../utils/sseClient';
|
import { startSseStream, SseEvent } from '../../../utils/sseClient';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
|
||||||
|
|
||||||
export interface AgentProgress {
|
export interface AgentProgress {
|
||||||
|
|
@ -123,6 +124,7 @@ interface UseWorkspaceReturn {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
const [files, setFiles] = useState<WorkspaceFile[]>([]);
|
||||||
|
|
@ -364,6 +366,24 @@ export function useWorkspace(instanceId: string): UseWorkspaceReturn {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRevealDownload: (event) => {
|
||||||
|
const data = event.item || event.data || {};
|
||||||
|
const fileName = _triggerRevealDownload(data);
|
||||||
|
if (fileName) {
|
||||||
|
const count = data.placeholderCount ?? 0;
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `reveal-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
message: t(
|
||||||
|
'De-anonymisierte Datei "{name}" heruntergeladen ({count} Platzhalter aufgelöst). Nicht gespeichert.',
|
||||||
|
{ name: fileName, count },
|
||||||
|
),
|
||||||
|
} as any,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
onWorkflowUpdated: (event) => {
|
onWorkflowUpdated: (event) => {
|
||||||
if (event.workflowId) setWorkflowId(event.workflowId);
|
if (event.workflowId) setWorkflowId(event.workflowId);
|
||||||
setWorkflowVersion(v => v + 1);
|
setWorkflowVersion(v => v + 1);
|
||||||
|
|
@ -732,3 +752,32 @@ function _buildAudioUrl(event: SseEvent): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Transient de-anonymized download: decode inline base64, trigger a browser download,
|
||||||
|
// and immediately revoke the object URL. The cleartext is never persisted client-side.
|
||||||
|
function _triggerRevealDownload(data: any): string | null {
|
||||||
|
const content = data?.content;
|
||||||
|
if (!content) return null;
|
||||||
|
const fileName = data.fileName || 'revealed.txt';
|
||||||
|
const mimeType = data.mimeType || 'text/plain';
|
||||||
|
try {
|
||||||
|
const byteChars = atob(content);
|
||||||
|
const byteArray = new Uint8Array(byteChars.length);
|
||||||
|
for (let i = 0; i < byteChars.length; i++) {
|
||||||
|
byteArray[i] = byteChars.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([byteArray], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = fileName;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return fileName;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to decode reveal download:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -97,20 +97,6 @@ export const FeatureProvider: React.FC<FeatureProviderProps> = ({ children }) =>
|
||||||
mandate.features.forEach(feature => {
|
mandate.features.forEach(feature => {
|
||||||
feature.instances.forEach(instance => {
|
feature.instances.forEach(instance => {
|
||||||
cache.set(instance.id, instance);
|
cache.set(instance.id, instance);
|
||||||
|
|
||||||
// DEBUG: Log permissions for chatbot instances
|
|
||||||
if (instance.featureCode === 'chatbot') {
|
|
||||||
console.log('🔍 [DEBUG] Chatbot Instance Permissions (loadFeatures):', {
|
|
||||||
instanceId: instance.id,
|
|
||||||
instanceLabel: instance.instanceLabel,
|
|
||||||
featureCode: instance.featureCode,
|
|
||||||
userRoles: instance.userRoles,
|
|
||||||
permissions: instance.permissions,
|
|
||||||
views: instance.permissions?.views,
|
|
||||||
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
|
||||||
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] || instance.permissions?.views?.['ui.feature.chatbot.conversations'] || instance.permissions?.views?.['_all'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -144,20 +130,6 @@ export const FeatureProvider: React.FC<FeatureProviderProps> = ({ children }) =>
|
||||||
mandate.features.forEach(feature => {
|
mandate.features.forEach(feature => {
|
||||||
feature.instances.forEach(instance => {
|
feature.instances.forEach(instance => {
|
||||||
cache.set(instance.id, instance);
|
cache.set(instance.id, instance);
|
||||||
|
|
||||||
// DEBUG: Log permissions for chatbot instances
|
|
||||||
if (instance.featureCode === 'chatbot') {
|
|
||||||
console.log('🔍 [DEBUG] Chatbot Instance Permissions:', {
|
|
||||||
instanceId: instance.id,
|
|
||||||
instanceLabel: instance.instanceLabel,
|
|
||||||
featureCode: instance.featureCode,
|
|
||||||
userRoles: instance.userRoles,
|
|
||||||
permissions: instance.permissions,
|
|
||||||
views: instance.permissions?.views,
|
|
||||||
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
|
||||||
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] || instance.permissions?.views?.['ui.feature.chatbot.conversations'] || instance.permissions?.views?.['_all'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -317,52 +317,6 @@
|
||||||
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: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
.chatHistorySection {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -690,13 +644,6 @@
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatbotTwoColumnLayout {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
height: auto;
|
|
||||||
max-height: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboardInputGrid {
|
.dashboardInputGrid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto auto auto;
|
grid-template-rows: auto auto auto;
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ export interface InstancePermissions {
|
||||||
*/
|
*/
|
||||||
export interface FeatureInstance {
|
export interface FeatureInstance {
|
||||||
id: string; // UUID der Instanz
|
id: string; // UUID der Instanz
|
||||||
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
|
featureCode: string; // "trustee", "workspace", "chatworkflow", etc.
|
||||||
mandateId: string; // Zugehöriger Mandant
|
mandateId: string; // Zugehöriger Mandant
|
||||||
mandateName: string; // Kurzzeichen / Slug des Mandanten (audit-stable)
|
mandateName: string; // Kurzzeichen / Slug des Mandanten (audit-stable)
|
||||||
mandateLabel?: string; // Voller Name des Mandanten (UI-Anzeige) — optional fuer Backwards-Compat
|
mandateLabel?: string; // Voller Name des Mandanten (UI-Anzeige) — optional fuer Backwards-Compat
|
||||||
|
|
@ -91,7 +91,7 @@ export interface FeatureInstance {
|
||||||
* Gruppiert alle Instanzen eines Feature-Typs
|
* Gruppiert alle Instanzen eines Feature-Typs
|
||||||
*/
|
*/
|
||||||
export interface MandateFeature {
|
export interface MandateFeature {
|
||||||
code: string; // "trustee", "chatbot", "chatworkflow", etc.
|
code: string; // "trustee", "workspace", "chatworkflow", etc.
|
||||||
label: string; // German plaintext i18n key
|
label: string; // German plaintext i18n key
|
||||||
icon: string; // Material/React Icon Name
|
icon: string; // Material/React Icon Name
|
||||||
instances: FeatureInstance[];
|
instances: FeatureInstance[];
|
||||||
|
|
@ -223,15 +223,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'files', label: 'Dateien', path: 'files' },
|
{ code: 'files', label: 'Dateien', path: 'files' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
chatbot: {
|
|
||||||
code: 'chatbot',
|
|
||||||
label: 'Chatbot',
|
|
||||||
icon: 'chat',
|
|
||||||
views: [
|
|
||||||
{ code: 'conversations', label: 'Konversationen', path: 'conversations' },
|
|
||||||
{ code: 'settings', label: 'Einstellungen', path: 'settings' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
realestate: {
|
realestate: {
|
||||||
code: 'realestate',
|
code: 'realestate',
|
||||||
label: 'Immobilien',
|
label: 'Immobilien',
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface SseEventHandlers {
|
||||||
onFileCreated?: (event: SseEvent) => void;
|
onFileCreated?: (event: SseEvent) => void;
|
||||||
onDataSourceAccess?: (event: SseEvent) => void;
|
onDataSourceAccess?: (event: SseEvent) => void;
|
||||||
onVoiceResponse?: (event: SseEvent) => void;
|
onVoiceResponse?: (event: SseEvent) => void;
|
||||||
|
onRevealDownload?: (event: SseEvent) => void;
|
||||||
onWorkflowUpdated?: (event: SseEvent) => void;
|
onWorkflowUpdated?: (event: SseEvent) => void;
|
||||||
onComplete?: (event: SseEvent) => void;
|
onComplete?: (event: SseEvent) => void;
|
||||||
onStopped?: (event: SseEvent) => void;
|
onStopped?: (event: SseEvent) => void;
|
||||||
|
|
@ -64,6 +65,7 @@ const _EVENT_ROUTER: Record<string, keyof SseEventHandlers> = {
|
||||||
fileCreated: 'onFileCreated',
|
fileCreated: 'onFileCreated',
|
||||||
dataSourceAccess: 'onDataSourceAccess',
|
dataSourceAccess: 'onDataSourceAccess',
|
||||||
voiceResponse: 'onVoiceResponse',
|
voiceResponse: 'onVoiceResponse',
|
||||||
|
revealDownload: 'onRevealDownload',
|
||||||
workflowUpdated: 'onWorkflowUpdated',
|
workflowUpdated: 'onWorkflowUpdated',
|
||||||
complete: 'onComplete',
|
complete: 'onComplete',
|
||||||
stopped: 'onStopped',
|
stopped: 'onStopped',
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
{
|
{
|
||||||
"navigationFallback": {
|
"navigationFallback": {
|
||||||
"rewrite": "/index.html",
|
"rewrite": "/index.html",
|
||||||
"exclude": ["/static/*", "/images/*", "/*.{png,jpg,gif,ico,json}"]
|
"exclude": ["/assets/*", "/static/*", "/images/*", "/*.{png,jpg,jpeg,gif,svg,ico,webp,json,js,css,map,woff,woff2,ttf}"]
|
||||||
},
|
},
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"route": "/assets/*",
|
||||||
|
"headers": {
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/*",
|
||||||
|
"headers": {
|
||||||
|
"Cache-Control": "no-cache, no-store, must-revalidate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
"mimeTypes": {
|
"mimeTypes": {
|
||||||
".json": "text/json"
|
".json": "application/json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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',
|
|
||||||
|
|
||||||
// Visual
|
|
||||||
icon: LuMessageSquare,
|
|
||||||
title: 'Chatbot',
|
|
||||||
subtitle: 'Chat with AI assistant',
|
|
||||||
|
|
||||||
// No header buttons (simpler than dashboard)
|
|
||||||
headerButtons: [],
|
|
||||||
|
|
||||||
// Content sections
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
id: 'chatbot-history',
|
|
||||||
type: 'chatHistory',
|
|
||||||
chatHistoryConfig: {
|
|
||||||
emptyMessage: 'No chat history yet. Start a conversation to see it here.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chatbot-messages',
|
|
||||||
type: 'messages',
|
|
||||||
messagesConfig: {
|
|
||||||
variant: 'chat',
|
|
||||||
showDocuments: true,
|
|
||||||
showMetadata: false,
|
|
||||||
showProgress: false,
|
|
||||||
emptyMessage: 'No messages yet. Start a conversation to see messages here.'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'chatbot-input',
|
|
||||||
type: 'inputForm',
|
|
||||||
inputFormConfig: {
|
|
||||||
hookFactory: createChatbotHook,
|
|
||||||
placeholder: 'Type your message here...',
|
|
||||||
buttonLabel: 'Send',
|
|
||||||
stopButtonLabel: 'Stop',
|
|
||||||
buttonIcon: IoMdSend,
|
|
||||||
stopButtonIcon: MdStop,
|
|
||||||
buttonVariant: 'primary',
|
|
||||||
stopButtonVariant: 'danger',
|
|
||||||
buttonSize: 'md',
|
|
||||||
textFieldSize: 'md',
|
|
||||||
showFileUpload: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
|
|
||||||
// Page behavior
|
|
||||||
persistent: true,
|
|
||||||
preserveState: true,
|
|
||||||
preload: true,
|
|
||||||
moduleEnabled: true,
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Lifecycle hooks
|
|
||||||
onActivate: async () => {
|
|
||||||
if (import.meta.env.DEV) console.log('Chatbot activated - state preserved');
|
|
||||||
},
|
|
||||||
onDeactivate: async () => {
|
|
||||||
if (import.meta.env.DEV) console.log('Chatbot deactivated - keeping state');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue