int #2
29 changed files with 121 additions and 2474 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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: [] },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
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;
|
||||
|
|
|
|||
14
src/main.tsx
14
src/main.tsx
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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