Merge pull request 'int' (#2) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s

Reviewed-on: #2
This commit is contained in:
p.motsch 2026-06-03 08:27:42 +00:00
commit 991952dde9
29 changed files with 121 additions and 2474 deletions

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
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 { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -134,7 +134,6 @@ function App() {
<Route path="rag-inventory" element={<RagInventoryPage />} />
{/* 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="speech" element={<Navigate to="/" replace />} />
@ -225,7 +224,6 @@ function App() {
<Route path="languages" element={null} />
<Route path="database-health" element={null} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="stt-benchmark" element={<SttBenchmarkPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
</Route>

View file

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

View file

@ -14,8 +14,6 @@ import type {
InstancePermissions,
AccessLevel,
} from '../types/mandate';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
// =============================================================================
// 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)
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:', {
mandateCount: data?.mandates?.length || 0,
totalInstances: data?.mandates
@ -239,7 +200,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
];
}

View file

@ -29,7 +29,7 @@ export interface MessageDocument {
export interface Message {
id: string;
workflowId?: string; // Legacy / backward compat
conversationId?: string; // New - from ChatbotMessage
conversationId?: string; // New - from chat conversation message
parentMessageId?: string;
documents?: MessageDocument[];
documentsLabel?: string;

View file

@ -48,7 +48,6 @@
flex-shrink: 0;
}
.scopeIcon,
.neutralizeIcon {
border: none;
background: transparent;
@ -60,7 +59,6 @@
transition: opacity 0.15s;
}
.scopeIcon:hover,
.neutralizeIcon:hover {
opacity: 1;
background: var(--bg-hover, rgba(0, 0, 0, 0.06));
@ -139,7 +137,6 @@
.fileRow:hover {
background: rgba(255, 255, 255, 0.05);
}
.scopeIcon:hover,
.neutralizeIcon:hover {
background: rgba(255, 255, 255, 0.08);
}

View file

@ -35,7 +35,7 @@ import {
} from 'react-icons/fa';
import { SiJira } from 'react-icons/si';
import api from '../../api';
import type { TreeNode, TreeNodeProvider, ScopeValue } from '../FormGenerator/FormGeneratorTree';
import type { TreeNode, TreeNodeProvider } from '../FormGenerator/FormGeneratorTree';
// ---------------------------------------------------------------------------
// Backend contract types
@ -180,14 +180,14 @@ function _mapBackendNode(
// neutralizeFields list). Scope and RAG are not field-level concepts.
node.neutralize = n.effectiveNeutralize;
} else if (_isFdsKind(n.kind)) {
// FDS records have neutralize + ragIndexEnabled, but no scope.
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
}
} else {
// DataSource family carries the full three-flag set.
node.scope = n.effectiveScope as ScopeValue | 'mixed';
// DataSource family: neutralize + ragIndexEnabled only.
// Scope was removed (personal sources must not be shared across
// scopes — privacy requirement, 2026-06).
node.neutralize = n.effectiveNeutralize;
if (n.supportsRag) {
node.ragIndexEnabled = n.effectiveRagIndexEnabled;
@ -287,7 +287,7 @@ export function createUdbSourcesProvider(
* permission check, and applies the cascade-reset. */
async function _patchFlag(
ids: string[],
flag: 'neutralize' | 'scope' | 'ragIndexEnabled',
flag: 'neutralize' | 'ragIndexEnabled',
value: unknown,
): Promise<void> {
for (const nodeKey of ids) {
@ -314,10 +314,8 @@ export function createUdbSourcesProvider(
return list.map((n) => _mapBackendNode(n, _onSettingsClick));
},
canPatchScope(node) {
const data = node.data;
// Scope only exists on DataSource family; FDS / synthetic containers / fields hide it.
return !!data && !_isSyntheticContainer(data.kind) && !_isFdsKind(data.kind);
canPatchScope(_node) {
return false;
},
canPatchNeutralize(node) {
@ -331,11 +329,8 @@ export function createUdbSourcesProvider(
return !!data && data.supportsRag === true && data.kind !== 'fdsField';
},
async patchScope(ids, scope, _cascadeChildren) {
// Backend cascades NULL on descendants automatically based on the
// existence of explicit child records; the cascadeChildren flag is the
// FilesTab convention and is irrelevant here.
await _patchFlag(ids, 'scope', scope);
async patchScope(_ids, _scope, _cascadeChildren) {
// Scope removed from personal sources (privacy, 2026-06). No-op.
},
async patchNeutralize(ids, neutralize) {

View file

@ -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();
apiMock.post.mockResolvedValue({ data: { nodesByParent: { 'personalRoot': [conn] } } });
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
@ -138,7 +138,7 @@ describe('UdbSourcesProvider.loadChildren', () => {
expect(result).toHaveLength(1);
const tn = result[0];
expect(tn.id).toBe('conn|c1');
expect(tn.scope).toBe('personal');
expect(tn.scope).toBeUndefined();
expect(tn.neutralize).toBe(false);
expect(tn.ragIndexEnabled).toBe(false);
});
@ -193,7 +193,16 @@ describe('UdbSourcesProvider.canPatch*', () => {
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({
data: { nodesByParent: { 'feat|m1|trustee|fi1': [_makeFdsTableNode()] } },
});
@ -221,16 +230,12 @@ describe('UdbSourcesProvider.canPatch*', () => {
// ---------------------------------------------------------------------------
describe('UdbSourcesProvider.patchScope', () => {
it('POSTs to /api/udb/node/{key}/flag/scope with the new value', async () => {
apiMock.post.mockResolvedValue({ data: {} });
it('is a no-op (scope removed from personal sources, 2026-06)', async () => {
const provider = createUdbSourcesProvider(_instanceId, vi.fn());
await provider.patchScope?.(['conn|c1'], 'mandate', true);
expect(apiMock.post).toHaveBeenCalledWith(
`/api/udb/node/${encodeURIComponent('conn|c1')}/flag/scope`,
{ value: 'mandate' },
);
expect(apiMock.post).not.toHaveBeenCalled();
});
});

View file

@ -11,8 +11,8 @@ export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/,
requireMandateForMount: false,
render: ({ instanceId, scopeKey }) => (
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
render: ({ mandateId, instanceId, scopeKey }) => (
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} persistentMandateId={mandateId} />
),
},
{

View file

@ -19,7 +19,7 @@ import {
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, FaMicrophone,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
@ -88,8 +88,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.database-health': <FaDatabase />,
'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />,
'page.admin.sttBenchmark': <FaMicrophone />,
'page.admin.stt-benchmark': <FaMicrophone />,
'page.admin.mandate-wizard': <FaHatWizard />,
'page.admin.mandateWizard': <FaHatWizard />,
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
@ -137,8 +135,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.graphicalEditor': <FaProjectDiagram />,
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />,
'feature.teamsbot': <FaHeadset />,
// Feature pages - Workspace

View file

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

View file

@ -127,43 +127,11 @@ export function useCanViewFeatureView(viewCode: string): boolean {
const { instance, featureCode } = useCurrentInstance();
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;
}
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)
if (views["_all"]) {
return true;

View file

@ -7,6 +7,20 @@ import './index.css'
import './styles/themes/light.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(
<StrictMode>
<App />

View file

@ -22,9 +22,6 @@ import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView';
// Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
// RealEstate Views
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
@ -86,16 +83,6 @@ const ChatworkflowFiles: React.FC = () => {
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
const NotFound: React.FC = () => {
const { t } = useLanguage();
@ -138,10 +125,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
runs: ChatworkflowRuns,
files: ChatworkflowFiles,
},
chatbot: {
conversations: ChatbotConversationsView,
settings: ChatbotSettings,
},
realestate: {
dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
@ -197,25 +180,6 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const viewCode = `${featureCode}-${view}`;
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
if (!isValid || !featureCode || !instance) {
return <NotFound />;

View file

@ -801,13 +801,9 @@
}
/* ============================================== */
/* Chatbot Configuration Styles */
/* Feature Instance Configuration Styles */
/* ============================================== */
.chatbotConfigSection {
margin-top: 0;
}
.configSectionTitle {
font-size: 1.125rem;
font-weight: 600;
@ -848,7 +844,7 @@
font-style: italic;
}
/* Multiselect styles for chatbot connectors */
/* Multiselect styles for connector selection */
.multiselectContainer {
padding: 0.75rem;
border: 1px solid var(--border-color);

View file

@ -16,7 +16,6 @@ import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { ChatbotConfigSection } from './ChatbotConfigSection';
import { TextField } from '../../components/UiComponents/TextField';
import styles from './Admin.module.css';
@ -56,13 +55,9 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [syncingWorkflowsInstance, setSyncingWorkflowsInstance] = useState<string | null>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Chatbot configuration state
// Instance creation state
const [createFeatureCode, setCreateFeatureCode] = useState<string>('');
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
const formDataRef = useRef<Record<string, any>>({});
@ -137,53 +132,17 @@ export const AdminFeatureAccessPage: React.FC = () => {
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, {
featureCode: createFeatureCode,
label: createLabel,
enabled: data.enabled !== false,
copyTemplateRoles: data.copyTemplateRoles !== false,
config: config
});
if (result.success) {
setShowCreateModal(false);
setCreateFeatureCode('');
setCreateLabel('');
formDataRef.current = {};
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
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
const handleEditClick = (instance: FeatureInstance) => {
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);
};
@ -233,51 +174,13 @@ export const AdminFeatureAccessPage: React.FC = () => {
if (!selectedMandateId || !editingInstance) return;
setIsSubmitting(true);
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, {
label: data.label,
enabled: data.enabled,
config: config
});
if (result.success) {
setShowEditModal(false);
setEditingInstance(null);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache
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={() => {
setCreateFeatureCode(f.code);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
>
{f.label || f.code}
@ -574,13 +473,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
</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 */}
{createFeatureCode && (
<div className={styles.configField} style={{ marginBottom: '1.5rem' }}>
@ -599,20 +491,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
</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 */}
{createFeatureCode && (
<div style={{ marginTop: '1.5rem' }}>
@ -625,10 +503,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
setCreateFeatureCode('');
setCreateLabel('');
formDataRef.current = {};
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
submitButtonText={t('Erstellen')}
cancelButtonText={t('Abbrechen')}
@ -679,28 +553,10 @@ export const AdminFeatureAccessPage: React.FC = () => {
onCancel={() => {
setShowEditModal(false);
setEditingInstance(null);
setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}}
submitButtonText={t('Speichern')}
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>

View file

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

View file

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

View file

@ -19,4 +19,3 @@ export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
export { AdminDatabaseHealthPage } from './AdminDatabaseHealthPage';
export { SttBenchmarkPage } from './SttBenchmarkPage';

View file

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

View file

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

View file

@ -1,5 +0,0 @@
/**
* Chatbot Views Export
*/
export { ChatbotConversationsView } from './ChatbotConversationsView';

View file

@ -61,18 +61,20 @@ type RightTab = 'activity' | 'preview';
interface WorkspacePageProps {
persistentInstanceId?: string;
persistentMandateId?: string;
}
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId }) => {
export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstanceId, persistentMandateId }) => {
const { t } = useLanguage();
const { instance } = useCurrentInstance();
const instanceId = persistentInstanceId || instance?.id || '';
const workspace = useWorkspace(instanceId);
const fileOps = useFileOperations();
const navigate = useNavigate();
const { mandateId, featureCode, instanceId: routeInstanceId } = useParams<{
const { mandateId: routeMandateId, featureCode, instanceId: routeInstanceId } = useParams<{
mandateId: string; featureCode: string; instanceId: string;
}>();
const mandateId = persistentMandateId || routeMandateId;
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const _leftResize = _useResizable(280, 200, 800);
@ -350,7 +352,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const [pendingAttachDsId, setPendingAttachDsId] = useState<string>('');
const _handleAttachDataSource = useCallback((dsId: string) => {
setPendingAttachDsId(dsId);
}, []);
workspace.refreshDataSources();
}, [workspace]);
const _handleDataSourceDrop = useCallback(async (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => {
try {

View file

@ -9,6 +9,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import api from '../../../api';
import { startSseStream, SseEvent } from '../../../utils/sseClient';
import { useLanguage } from '../../../providers/language/LanguageContext';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
export interface AgentProgress {
@ -123,6 +124,7 @@ interface UseWorkspaceReturn {
}
export function useWorkspace(instanceId: string): UseWorkspaceReturn {
const { t } = useLanguage();
const [messages, setMessages] = useState<Message[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
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) => {
if (event.workflowId) setWorkflowId(event.workflowId);
setWorkflowVersion(v => v + 1);
@ -732,3 +752,32 @@ function _buildAudioUrl(event: SseEvent): string | 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;
}
}

View file

@ -97,20 +97,6 @@ export const FeatureProvider: React.FC<FeatureProviderProps> = ({ children }) =>
mandate.features.forEach(feature => {
feature.instances.forEach(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 => {
feature.instances.forEach(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'],
});
}
});
});
});

View file

@ -317,52 +317,6 @@
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 {
flex: 1;
display: flex;
@ -690,13 +644,6 @@
grid-row: 2;
}
.chatbotTwoColumnLayout {
grid-template-columns: 1fr;
height: auto;
max-height: none;
overflow: visible;
}
.dashboardInputGrid {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;

View file

@ -73,7 +73,7 @@ export interface InstancePermissions {
*/
export interface FeatureInstance {
id: string; // UUID der Instanz
featureCode: string; // "trustee", "chatbot", "chatworkflow", etc.
featureCode: string; // "trustee", "workspace", "chatworkflow", etc.
mandateId: string; // Zugehöriger Mandant
mandateName: string; // Kurzzeichen / Slug des Mandanten (audit-stable)
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
*/
export interface MandateFeature {
code: string; // "trustee", "chatbot", "chatworkflow", etc.
code: string; // "trustee", "workspace", "chatworkflow", etc.
label: string; // German plaintext i18n key
icon: string; // Material/React Icon Name
instances: FeatureInstance[];
@ -223,15 +223,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
{ 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: {
code: 'realestate',
label: 'Immobilien',

View file

@ -27,6 +27,7 @@ export interface SseEventHandlers {
onFileCreated?: (event: SseEvent) => void;
onDataSourceAccess?: (event: SseEvent) => void;
onVoiceResponse?: (event: SseEvent) => void;
onRevealDownload?: (event: SseEvent) => void;
onWorkflowUpdated?: (event: SseEvent) => void;
onComplete?: (event: SseEvent) => void;
onStopped?: (event: SseEvent) => void;
@ -64,6 +65,7 @@ const _EVENT_ROUTER: Record<string, keyof SseEventHandlers> = {
fileCreated: 'onFileCreated',
dataSourceAccess: 'onDataSourceAccess',
voiceResponse: 'onVoiceResponse',
revealDownload: 'onRevealDownload',
workflowUpdated: 'onWorkflowUpdated',
complete: 'onComplete',
stopped: 'onStopped',

View file

@ -1,9 +1,23 @@
{
"navigationFallback": {
"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": {
".json": "text/json"
".json": "application/json"
}
}
}

View file

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