From f0a7daea02b9504228a3b7378d85abf92f4a2516 Mon Sep 17 00:00:00 2001 From: Ida Dittrich Date: Mon, 23 Feb 2026 07:28:06 +0100 Subject: [PATCH] nicht fertig; Stand Demo Kessler --- src/App.tsx | 3 + src/api/chatbotApi.ts | 5 +- src/api/chatbotV2Api.ts | 240 +++++++ .../UiComponents/Messages/MessagesTypes.ts | 10 +- src/config/pageRegistry.tsx | 6 + src/hooks/useChatbot.ts | 25 +- src/hooks/useChatbotV2.ts | 326 +++++++++ src/pages/FeatureView.tsx | 10 + src/pages/admin/AdminFeatureAccessPage.tsx | 63 +- src/pages/admin/ChatbotConfigSection.tsx | 68 +- .../chatbotV2/ChatbotV2ConversationsView.tsx | 448 +++++++++++++ .../views/chatbotV2/ChatbotV2Views.module.css | 619 ++++++++++++++++++ src/pages/views/chatbotV2/index.ts | 5 + src/types/mandate.ts | 11 + 14 files changed, 1789 insertions(+), 50 deletions(-) create mode 100644 src/api/chatbotV2Api.ts create mode 100644 src/hooks/useChatbotV2.ts create mode 100644 src/pages/views/chatbotV2/ChatbotV2ConversationsView.tsx create mode 100644 src/pages/views/chatbotV2/ChatbotV2Views.module.css create mode 100644 src/pages/views/chatbotV2/index.ts diff --git a/src/App.tsx b/src/App.tsx index 32efece..d1c9c5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -151,6 +151,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/api/chatbotApi.ts b/src/api/chatbotApi.ts index 81d57a4..90f515e 100644 --- a/src/api/chatbotApi.ts +++ b/src/api/chatbotApi.ts @@ -16,7 +16,8 @@ export interface UserInputRequest { export interface ChatbotWorkflow { id: string; - mandateId: string; + mandateId?: string; // Optional - not in ChatbotConversation + featureInstanceId?: string; // From ChatbotConversation status: string; name?: string; currentRound?: number; @@ -254,7 +255,7 @@ export async function getChatbotThreadsApi( return { items: Array.isArray(data.items) ? data.items : [], - metadata: data.metadata || {} + metadata: data.pagination ?? data.metadata ?? {} }; } diff --git a/src/api/chatbotV2Api.ts b/src/api/chatbotV2Api.ts new file mode 100644 index 0000000..2ed4c1b --- /dev/null +++ b/src/api/chatbotV2Api.ts @@ -0,0 +1,240 @@ +/** + * Chatbot V2 API + * + * Context-aware chat: upload files for extraction first, then chat. + * Endpoints: /api/chatbotv2/{instanceId}/... + */ + +import { ApiRequestOptions } from '../hooks/useApi'; +import api from '../api'; +import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils'; +import { Message } from '../components/UiComponents/Messages/MessagesTypes'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ChatbotV2Workflow { + id: string; + mandateId?: string; + featureInstanceId?: string; + status: string; // extracting | ready | running | stopped + name?: string; + currentRound?: number; + lastActivity?: number; + startedAt?: number; + extractedContextId?: string; + contextFiles?: Array<{ fileId: string; fileName: string; mimeType?: string }>; + [key: string]: any; +} + +export interface UploadChatbotV2Request { + listFileId: string[]; +} + +export interface UploadChatbotV2Response { + conversationId: string; + status: string; + message?: string; +} + +export interface StartChatbotV2Request { + prompt: string; + workflowId: string; // conversationId - required for V2 + userLanguage?: string; +} + +export interface ChatDataItem { + type: 'message' | 'log' | 'stat' | 'document' | 'stopped' | 'status'; + createdAt?: number; + item: Message | any; + label?: string; +} + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; +export type SSEEventHandler = (item: ChatDataItem) => void; + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +/** + * Upload files as context and start extraction. + * Files must be uploaded to central storage first via /api/files/upload. + */ +export async function uploadChatbotV2Api( + request: ApiRequestFunction, + instanceId: string, + listFileId: string[] +): Promise { + const data = await request({ + url: `/api/chatbotv2/${instanceId}/upload`, + method: 'post', + data: { listFileId } + }); + return data as UploadChatbotV2Response; +} + +/** + * Get chatbot V2 threads (conversations) + */ +export async function getChatbotV2ThreadsApi( + request: ApiRequestFunction, + instanceId: string, + pagination?: { page?: number; pageSize?: number } +): Promise<{ items: ChatbotV2Workflow[]; pagination?: any }> { + const params = pagination ? { pagination: JSON.stringify(pagination) } : undefined; + const data = await request({ + url: `/api/chatbotv2/${instanceId}/threads`, + method: 'get', + params + }) as any; + return { + items: Array.isArray(data.items) ? data.items : [], + pagination: data.pagination ?? data.metadata + }; +} + +/** + * Get a specific thread with chat data + */ +export async function getChatbotV2ThreadApi( + request: ApiRequestFunction, + instanceId: string, + workflowId: string +): Promise<{ workflow: ChatbotV2Workflow; chatData: { items: ChatDataItem[] } }> { + const data = await request({ + url: `/api/chatbotv2/${instanceId}/threads`, + method: 'get', + params: { workflowId } + }) as { workflow: ChatbotV2Workflow; chatData: { items: ChatDataItem[] } }; + return { + workflow: data.workflow, + chatData: data.chatData || { items: [] } + }; +} + +/** + * Start or continue chat with SSE streaming. + * Requires conversationId (workflowId) - must have completed extraction first. + */ +export async function startChatbotV2StreamApi( + instanceId: string, + requestBody: StartChatbotV2Request, + onEvent: SSEEventHandler, + onError?: (error: Error) => void, + onComplete?: () => void +): Promise { + try { + const url = `/api/chatbotv2/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`; + const baseURL = api.defaults.baseURL || ''; + const fullURL = baseURL + url; + + const headers: Record = { 'Content-Type': 'application/json' }; + const authToken = localStorage.getItem('authToken'); + if (authToken) headers['Authorization'] = `Bearer ${authToken}`; + if (!getCSRFToken()) generateAndStoreCSRFToken(); + addCSRFTokenToHeaders(headers); + + const response = await fetch(fullURL, { + method: 'POST', + headers, + body: JSON.stringify({ + prompt: requestBody.prompt, + userLanguage: requestBody.userLanguage || navigator.language || 'de' + }), + credentials: 'include' + }); + + 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; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + 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 { + // ignore parse errors + } + } + } + } + + 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 { + // ignore + } + } + } + } + + onComplete?.(); + } finally { + reader.releaseLock(); + } + } catch (error: any) { + if (onError) { + onError(error instanceof Error ? error : new Error(String(error))); + } else { + throw error; + } + } +} + +/** + * Stop a running chat + */ +export async function stopChatbotV2Api( + request: ApiRequestFunction, + instanceId: string, + workflowId: string +): Promise { + const data = await request({ + url: `/api/chatbotv2/${instanceId}/stop/${workflowId}`, + method: 'post' + }); + return data as ChatbotV2Workflow; +} + +/** + * Delete a conversation + */ +export async function deleteChatbotV2Api( + request: ApiRequestFunction, + instanceId: string, + workflowId: string +): Promise { + await request({ + url: `/api/chatbotv2/${instanceId}/conversations/${workflowId}`, + method: 'delete' + }); +} diff --git a/src/components/UiComponents/Messages/MessagesTypes.ts b/src/components/UiComponents/Messages/MessagesTypes.ts index 240f577..9ab4d6c 100644 --- a/src/components/UiComponents/Messages/MessagesTypes.ts +++ b/src/components/UiComponents/Messages/MessagesTypes.ts @@ -22,7 +22,8 @@ export interface MessageDocument { */ export interface Message { id: string; - workflowId: string; + workflowId?: string; // Legacy / backward compat + conversationId?: string; // New - from ChatbotMessage parentMessageId?: string; documents?: MessageDocument[]; documentsLabel?: string; @@ -43,6 +44,13 @@ export interface Message { actionProgress?: string; } +/** + * Get the conversation/thread ID from a message (supports both workflowId and conversationId) + */ +export function getConversationId(message: Message): string { + return message.workflowId ?? message.conversationId ?? ''; +} + /** * Message display variant */ diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 78a4db5..c52f039 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -106,7 +106,13 @@ export const PAGE_ICONS: Record = { 'feature.chatplayground': , 'feature.codeeditor': , 'feature.automation': , + 'page.feature.chatbot.conversations': , 'feature.chatbot': , + 'page.feature.chatbotv2.conversations': , + 'page.feature.chatbotv2.upload': , + 'page.feature.chatbotv2.chat': , + 'page.feature.chatbotv2.threads': , + 'feature.chatbotv2': , 'feature.teamsbot': , }; diff --git a/src/hooks/useChatbot.ts b/src/hooks/useChatbot.ts index 6fb79c0..600a34e 100644 --- a/src/hooks/useChatbot.ts +++ b/src/hooks/useChatbot.ts @@ -16,7 +16,7 @@ import { type ChatDataItem, type StartChatbotRequest } from '../api/chatbotApi'; -import { Message } from '../components/UiComponents/Messages/MessagesTypes'; +import { Message, getConversationId } from '../components/UiComponents/Messages/MessagesTypes'; import { useInstanceId } from './useCurrentInstance'; export interface ChatbotHookReturn { @@ -170,7 +170,8 @@ export function useChatbot(): ChatbotHookReturn { const tempUserMessageId = `temp-user-${Date.now()}`; const userMessage: Message = { id: tempUserMessageId, - workflowId: currentWorkflowId || '', + workflowId: currentWorkflowId || undefined, + conversationId: currentWorkflowId || undefined, role: 'user', message: inputMessageContent, publishedAt: Date.now() @@ -211,7 +212,7 @@ export function useChatbot(): ChatbotHookReturn { return; } - // Handle workflow update + // Handle workflow update (includes name updates from background task) if (item.type === 'stat' && item.item?.id) { newWorkflowId = item.item.id; setCurrentWorkflowId(item.item.id); @@ -223,15 +224,19 @@ export function useChatbot(): ChatbotHookReturn { 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 workflowId from message if available and not yet set - if (message.workflowId) { - const extractedWorkflowId = message.workflowId; + + // 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; @@ -334,10 +339,10 @@ export function useChatbot(): ChatbotHookReturn { // 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 + // Try to extract workflowId from the latest message (supports workflowId and conversationId) const latestMessage = messages[messages.length - 1]; - if (latestMessage.workflowId) { - workflowIdToStop = latestMessage.workflowId; + workflowIdToStop = getConversationId(latestMessage) || undefined; + if (workflowIdToStop) { console.log('Extracted workflowId from latest message:', workflowIdToStop); } } diff --git a/src/hooks/useChatbotV2.ts b/src/hooks/useChatbotV2.ts new file mode 100644 index 0000000..f3634c3 --- /dev/null +++ b/src/hooks/useChatbotV2.ts @@ -0,0 +1,326 @@ +/** + * useChatbotV2 Hook + * + * Context-aware chatbot: add context (upload + extract) first, then chat. + * Flow: Upload files -> Extract context -> Chat with context + */ + +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useApiRequest } from './useApi'; +import { useFileOperations } from './useFiles'; +import { + uploadChatbotV2Api, + getChatbotV2ThreadsApi, + getChatbotV2ThreadApi, + startChatbotV2StreamApi, + stopChatbotV2Api, + deleteChatbotV2Api, + type ChatbotV2Workflow, + type ChatDataItem +} from '../api/chatbotV2Api'; +import { Message, getConversationId } from '../components/UiComponents/Messages/MessagesTypes'; +import { useInstanceId } from './useCurrentInstance'; + +export interface UseChatbotV2Return { + // Threads + threads: ChatbotV2Workflow[]; + selectedThreadId: string | null; + loadingThreads: boolean; + error: string | null; + + // Messages + messages: Message[]; + loadingMessages: boolean; + + // Current workflow + currentWorkflowId: string | null; + selectedThread: ChatbotV2Workflow | null; + isStreaming: boolean; + streamingStatus: string | null; + + // Add context (upload + extract) + uploadingContext: boolean; + pendingFiles: Array<{ id: string; name: string }>; + addContextFiles: (files: File[]) => Promise; + addExistingFile: (id: string, fileName: string) => void; + submitContext: () => Promise; // Returns conversationId or null + clearPendingFiles: () => void; + removePendingFile: (id: string) => void; + + // Actions + selectThread: (workflowId: string) => Promise; + createNewThread: () => void; + sendMessage: (input: string) => Promise; + stopStreaming: () => Promise; + deleteThread: (workflowId: string) => Promise; + refreshThreads: () => Promise; + + // Input + inputValue: string; + setInputValue: (value: string) => void; +} + +export function useChatbotV2(): UseChatbotV2Return { + const { request } = useApiRequest(); + const { handleFileUpload } = useFileOperations(); + const instanceId = useInstanceId(); + const isMountedRef = useRef(true); + + const [threads, setThreads] = useState([]); + const [selectedThreadId, setSelectedThreadId] = useState(null); + const [loadingThreads, setLoadingThreads] = useState(false); + const [messages, setMessages] = useState([]); + const [loadingMessages, setLoadingMessages] = useState(false); + const [currentWorkflowId, setCurrentWorkflowId] = useState(null); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingStatus, setStreamingStatus] = useState(null); + const [error, setError] = useState(null); + const [inputValue, setInputValue] = useState(''); + const [uploadingContext, setUploadingContext] = useState(false); + const [pendingFiles, setPendingFiles] = useState>([]); + + useEffect(() => { + isMountedRef.current = true; + return () => { isMountedRef.current = false; }; + }, []); + + const refreshThreads = useCallback(async () => { + if (!instanceId) return; + setLoadingThreads(true); + setError(null); + try { + const result = await getChatbotV2ThreadsApi(request, instanceId); + if (isMountedRef.current) setThreads(result.items || []); + } catch (err: any) { + if (isMountedRef.current) setError(err.message || 'Fehler beim Laden der Konversationen'); + } finally { + if (isMountedRef.current) setLoadingThreads(false); + } + }, [request, instanceId]); + + const loadThreadMessages = useCallback(async (workflowId: string) => { + if (!instanceId) return; + setLoadingMessages(true); + setError(null); + try { + const result = await getChatbotV2ThreadApi(request, instanceId, workflowId); + if (isMountedRef.current) { + 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) { + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Laden der Nachrichten'); + setMessages([]); + } + } finally { + if (isMountedRef.current) setLoadingMessages(false); + } + }, [request, instanceId]); + + const selectThread = useCallback(async (workflowId: string) => { + setSelectedThreadId(workflowId); + await loadThreadMessages(workflowId); + }, [loadThreadMessages]); + + const createNewThread = useCallback(() => { + setSelectedThreadId(null); + setMessages([]); + setCurrentWorkflowId(null); + setPendingFiles([]); + setInputValue(''); + }, []); + + const addContextFiles = useCallback(async (files: File[]) => { + if (!files.length) return; + setError(null); + const added: Array<{ id: string; name: string }> = []; + for (const file of Array.from(files)) { + const result = await handleFileUpload(file); + if (result.success && result.fileData) { + const fd = result.fileData.file || result.fileData; + if (fd?.id) { + added.push({ id: fd.id, name: fd.fileName || file.name }); + } + } + } + if (isMountedRef.current && added.length) { + setPendingFiles(prev => [...prev, ...added]); + } + }, [handleFileUpload]); + + const addExistingFile = useCallback((id: string, fileName: string) => { + setError(null); + setPendingFiles(prev => { + if (prev.some(f => f.id === id)) return prev; + return [...prev, { id, name: fileName }]; + }); + }, []); + + const clearPendingFiles = useCallback(() => setPendingFiles([]), []); + const removePendingFile = useCallback((id: string) => { + setPendingFiles(prev => prev.filter(f => f.id !== id)); + }, []); + + const submitContext = useCallback(async (): Promise => { + if (!instanceId || pendingFiles.length === 0) return null; + setUploadingContext(true); + setError(null); + try { + const listFileId = pendingFiles.map(f => f.id); + const result = await uploadChatbotV2Api(request, instanceId, listFileId); + if (isMountedRef.current && result.conversationId) { + setPendingFiles([]); + setSelectedThreadId(result.conversationId); + setCurrentWorkflowId(result.conversationId); + await loadThreadMessages(result.conversationId); + await refreshThreads(); + return result.conversationId; + } + return null; + } catch (err: any) { + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Extrahieren des Kontexts'); + } + return null; + } finally { + if (isMountedRef.current) setUploadingContext(false); + } + }, [instanceId, pendingFiles, request, loadThreadMessages, refreshThreads]); + + const sendMessage = useCallback(async (input: string) => { + if (!input.trim() || isStreaming || !instanceId || !currentWorkflowId) return; + setError(null); + setIsStreaming(true); + setStreamingStatus(null); + + const tempUserMessageId = `temp-user-${Date.now()}`; + const userMessage: Message = { + id: tempUserMessageId, + conversationId: currentWorkflowId, + role: 'user', + message: input.trim(), + publishedAt: Date.now() + }; + setMessages(prev => [...prev, userMessage]); + setInputValue(''); + + try { + await startChatbotV2StreamApi( + instanceId, + { prompt: input, workflowId: currentWorkflowId, userLanguage: navigator.language || 'de' }, + (item: ChatDataItem) => { + if (!isMountedRef.current) return; + if (item.type === 'stopped') { + setIsStreaming(false); + setStreamingStatus(null); + return; + } + if (item.type === 'status') { + const label = item.label || (item.item as any)?.label || ''; + setStreamingStatus(label); + return; + } + if (item.type === 'message' && item.item) { + const message = item.item as Message; + const extractedId = getConversationId(message); + if (extractedId && !currentWorkflowId) setCurrentWorkflowId(extractedId); + + setMessages(prev => { + if (prev.some(m => m.id === message.id)) return prev; + if (message.status === 'first') { + return prev.map(m => (m.id === tempUserMessageId ? message : m)); + } + const isDup = prev.some(m => + m.id === message.id || + (m.role === message.role && m.message === message.message && + m.publishedAt && message.publishedAt && + Math.abs(m.publishedAt - message.publishedAt) < 1000) + ); + if (isDup) return prev; + return [...prev, message]; + }); + } + }, + (err) => { + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Senden'); + setIsStreaming(false); + } + }, + () => { + if (isMountedRef.current) { + setIsStreaming(false); + setStreamingStatus(null); + refreshThreads(); + } + } + ); + } catch (err: any) { + if (isMountedRef.current) { + setError(err.message || 'Fehler beim Senden'); + setIsStreaming(false); + } + } + }, [currentWorkflowId, isStreaming, instanceId, refreshThreads]); + + const stopStreaming = useCallback(async () => { + if (!instanceId || !isStreaming) return; + setIsStreaming(false); + const workflowId = currentWorkflowId || (messages.length ? getConversationId(messages[messages.length - 1]) : null); + if (workflowId) { + stopChatbotV2Api(request, instanceId, workflowId).catch(() => {}); + } + }, [currentWorkflowId, isStreaming, instanceId, request, messages]); + + const deleteThread = useCallback(async (workflowId: string) => { + if (!instanceId) return; + const previousThreads = threads; + setThreads(prev => prev.filter(t => t.id !== workflowId)); + if (selectedThreadId === workflowId) createNewThread(); + try { + await deleteChatbotV2Api(request, instanceId, workflowId); + await refreshThreads(); + } catch (err: any) { + setThreads(previousThreads); + setError(err.message || 'Fehler beim Löschen'); + } + }, [request, instanceId, selectedThreadId, threads, createNewThread, refreshThreads]); + + useEffect(() => { + if (instanceId) refreshThreads(); + }, [instanceId, refreshThreads]); + + const selectedThread = threads.find(t => t.id === selectedThreadId) || null; + + return { + threads, + selectedThreadId, + loadingThreads, + error, + messages, + loadingMessages, + currentWorkflowId, + selectedThread, + isStreaming, + streamingStatus, + uploadingContext, + pendingFiles, + addContextFiles, + addExistingFile, + submitContext, + clearPendingFiles, + removePendingFile, + selectThread, + createNewThread, + sendMessage, + stopStreaming, + deleteThread, + refreshThreads, + inputValue, + setInputValue + }; +} diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 5c906b7..29e13e1 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -23,6 +23,9 @@ import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccounting // Chatbot Views import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; +// Chatbot V2 Views +import { ChatbotV2ConversationsView } from './views/chatbotV2/ChatbotV2ConversationsView'; + // RealEstate Views import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; @@ -116,6 +119,13 @@ const VIEW_COMPONENTS: Record> = { conversations: ChatbotConversationsView, settings: ChatbotSettings, }, + chatbotv2: { + dashboard: ChatbotV2ConversationsView, + conversations: ChatbotV2ConversationsView, + upload: ChatbotV2ConversationsView, + chat: ChatbotV2ConversationsView, + threads: ChatbotV2ConversationsView, + }, realestate: { dashboard: RealEstatePekView, 'instance-roles': RealEstateInstanceRolesPlaceholder, diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 1f7c83c..40727b6 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -54,6 +54,7 @@ export const AdminFeatureAccessPage: React.FC = () => { const [chatbotConnectors, setChatbotConnectors] = useState(['preprocessor']); // Array for multiselect (database connectors only) const [chatbotSystemPrompt, setChatbotSystemPrompt] = useState(''); const [chatbotEnableWebResearch, setChatbotEnableWebResearch] = useState(true); // Enable Tavily web research + const [chatbotAllowedProviders, setChatbotAllowedProviders] = useState([]); // Allowed LLM providers (empty = all) // Ref to track form data for featureCode detection const formDataRef = useRef>({}); @@ -128,23 +129,15 @@ export const AdminFeatureAccessPage: React.FC = () => { setIsSubmitting(false); return; } - if (chatbotConnectors.length === 0) { - showError('Fehler', 'Mindestens ein Connector muss ausgewählt werden.'); - setIsSubmitting(false); - return; - } - - // Use first connector as primary type (for backward compatibility) - // Store all connectors in types array - const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : 'preprocessor'; + const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null; config = { - connector: { - types: chatbotConnectors.length > 0 ? chatbotConnectors : ['preprocessor'], // Array of selected connectors - type: primaryConnector, // Primary connector (for backward compatibility) + connector: chatbotConnectors.length > 0 ? { + types: chatbotConnectors, + type: primaryConnector, customConnectorClass: null - }, + } : undefined, prompts: { - useCustomPrompts: true, // Always true since system prompt is required + useCustomPrompts: true, customAnalysisPrompt: chatbotSystemPrompt, customFinalAnswerPrompt: chatbotSystemPrompt }, @@ -153,7 +146,8 @@ export const AdminFeatureAccessPage: React.FC = () => { enableWebResearch: chatbotEnableWebResearch, enableRetryOnEmpty: true, maxRetryAttempts: 2 - } + }, + allowedProviders: chatbotAllowedProviders }; } @@ -172,6 +166,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setChatbotConnectors(['preprocessor']); setChatbotSystemPrompt(''); setChatbotEnableWebResearch(true); + setChatbotAllowedProviders([]); fetchInstances(selectedMandateId); loadFeatures(); // Refresh global navigation cache showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`); @@ -202,13 +197,16 @@ export const AdminFeatureAccessPage: React.FC = () => { // 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.length > 0 ? connectorTypes : ['preprocessor']); + setChatbotConnectors(connectorTypes); setChatbotSystemPrompt(config?.prompts?.customAnalysisPrompt || config?.prompts?.customFinalAnswerPrompt || ''); - setChatbotEnableWebResearch(config?.behavior?.enableWebResearch !== false); // Default to true if not set + setChatbotEnableWebResearch(config?.behavior?.enableWebResearch !== false); + setChatbotAllowedProviders(Array.isArray(config?.allowedProviders) ? config.allowedProviders + : Array.isArray(config?.model?.allowedProviders) ? config.model.allowedProviders : []); } else { - setChatbotConnectors(['preprocessor']); + setChatbotConnectors([]); setChatbotSystemPrompt(''); setChatbotEnableWebResearch(true); + setChatbotAllowedProviders([]); } setShowEditModal(true); }; @@ -227,23 +225,15 @@ export const AdminFeatureAccessPage: React.FC = () => { setIsSubmitting(false); return; } - if (chatbotConnectors.length === 0) { - showError('Fehler', 'Mindestens ein Connector muss ausgewählt werden.'); - setIsSubmitting(false); - return; - } - - // Merge with existing config if it exists const existingConfig = editingInstance.config as any || {}; - // Use first connector as primary type (for backward compatibility) - const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : 'preprocessor'; + const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null; config = { ...existingConfig, - connector: { - types: chatbotConnectors.length > 0 ? chatbotConnectors : ['preprocessor'], // Array of selected connectors - type: primaryConnector, // Primary connector (for backward compatibility) + 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, @@ -255,7 +245,8 @@ export const AdminFeatureAccessPage: React.FC = () => { enableWebResearch: chatbotEnableWebResearch, enableRetryOnEmpty: existingConfig.behavior?.enableRetryOnEmpty !== false, maxRetryAttempts: existingConfig.behavior?.maxRetryAttempts || 2 - } + }, + allowedProviders: chatbotAllowedProviders }; } @@ -269,6 +260,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setEditingInstance(null); setChatbotConnectors(['preprocessor']); setChatbotSystemPrompt(''); + setChatbotAllowedProviders([]); fetchInstances(selectedMandateId); loadFeatures(); // Refresh global navigation cache showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`); @@ -547,6 +539,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setChatbotConnectors(['preprocessor']); setChatbotSystemPrompt(''); setChatbotEnableWebResearch(true); + setChatbotAllowedProviders([]); }} placeholder="Feature auswählen (erforderlich)" className={styles.configSelect} @@ -589,9 +582,11 @@ export const AdminFeatureAccessPage: React.FC = () => { connectors={chatbotConnectors} systemPrompt={chatbotSystemPrompt} enableWebResearch={chatbotEnableWebResearch} + allowedProviders={chatbotAllowedProviders} onConnectorsChange={setChatbotConnectors} onSystemPromptChange={setChatbotSystemPrompt} onEnableWebResearchChange={setChatbotEnableWebResearch} + onAllowedProvidersChange={setChatbotAllowedProviders} /> )} @@ -610,6 +605,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setChatbotConnectors(['preprocessor']); setChatbotSystemPrompt(''); setChatbotEnableWebResearch(true); + setChatbotAllowedProviders([]); }} submitButtonText="Erstellen" cancelButtonText="Abbrechen" @@ -663,6 +659,7 @@ export const AdminFeatureAccessPage: React.FC = () => { setChatbotConnectors(['preprocessor']); setChatbotSystemPrompt(''); setChatbotEnableWebResearch(true); + setChatbotAllowedProviders([]); }} submitButtonText="Speichern" cancelButtonText="Abbrechen" @@ -674,9 +671,11 @@ export const AdminFeatureAccessPage: React.FC = () => { connectors={chatbotConnectors} systemPrompt={chatbotSystemPrompt} enableWebResearch={chatbotEnableWebResearch} + allowedProviders={chatbotAllowedProviders} onConnectorsChange={setChatbotConnectors} onSystemPromptChange={setChatbotSystemPrompt} onEnableWebResearchChange={setChatbotEnableWebResearch} + onAllowedProvidersChange={setChatbotAllowedProviders} /> )} diff --git a/src/pages/admin/ChatbotConfigSection.tsx b/src/pages/admin/ChatbotConfigSection.tsx index e03b634..7d208b8 100644 --- a/src/pages/admin/ChatbotConfigSection.tsx +++ b/src/pages/admin/ChatbotConfigSection.tsx @@ -5,10 +5,20 @@ * Only shown when featureCode is "chatbot" */ -import React from 'react'; +import React, { useEffect } from 'react'; import { TextField } from '../../components/UiComponents/TextField'; +import { useBilling } from '../../hooks/useBilling'; import styles from './Admin.module.css'; +const PROVIDER_LABELS: Record = { + anthropic: 'Anthropic (Claude)', + openai: 'OpenAI (GPT)', + perplexity: 'Perplexity', + tavily: 'Tavily (Web Search)', + privatellm: 'Private LLM', + internal: 'Internal', +}; + export interface ChatbotConfig { connector: string; systemPrompt: string; @@ -18,32 +28,50 @@ 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 = ({ connectors, systemPrompt, enableWebResearch, + allowedProviders, onConnectorsChange, onSystemPromptChange, - onEnableWebResearchChange + onEnableWebResearchChange, + onAllowedProvidersChange, }) => { + const { allowedProviders: availableProviders, loadAllowedProviders, loading: providersLoading } = useBilling(); + + useEffect(() => { + if (availableProviders.length === 0 && !providersLoading) { + loadAllowedProviders(); + } + }, []); + const availableConnectors = [ { id: 'preprocessor', label: 'Althaus Preprocessor', value: 'preprocessor' } ]; const handleConnectorToggle = (connectorValue: string) => { if (connectors.includes(connectorValue)) { - // Remove connector onConnectorsChange(connectors.filter(c => c !== connectorValue)); } else { - // Add connector onConnectorsChange([...connectors, connectorValue]); } }; + + const handleProviderToggle = (provider: string) => { + if (allowedProviders.includes(provider)) { + onAllowedProvidersChange(allowedProviders.filter(p => p !== provider)); + } else { + onAllowedProvidersChange([...allowedProviders, provider]); + } + }; return (
@@ -69,7 +97,7 @@ export const ChatbotConfigSection: React.FC = ({
{connectors.length === 0 && (

- Bitte wählen Sie mindestens einen Connector aus. + Ohne Connector werden keine SQL-Abfragen unterstützt.

)} @@ -89,6 +117,36 @@ export const ChatbotConfigSection: React.FC = ({

+
+ +
+ {providersLoading ? ( + Lade Anbieter... + ) : ( + availableProviders.map(provider => ( + + )) + )} +
+ {allowedProviders.length === 0 && !providersLoading && ( +

+ Keine Einschränkung – alle verfügbaren Anbieter werden verwendet. +

+ )} +
+