nicht fertig; Stand Demo Kessler

This commit is contained in:
Ida Dittrich 2026-02-23 07:28:06 +01:00
parent 39e74110cd
commit f0a7daea02
14 changed files with 1789 additions and 50 deletions

View file

@ -151,6 +151,9 @@ function App() {
<Route path="runs" element={<FeatureViewPage view="runs" />} /> <Route path="runs" element={<FeatureViewPage view="runs" />} />
<Route path="files" element={<FeatureViewPage view="files" />} /> <Route path="files" element={<FeatureViewPage view="files" />} />
<Route path="conversations" element={<FeatureViewPage view="conversations" />} /> <Route path="conversations" element={<FeatureViewPage view="conversations" />} />
<Route path="upload" element={<FeatureViewPage view="upload" />} />
<Route path="chat" element={<FeatureViewPage view="chat" />} />
<Route path="threads" element={<FeatureViewPage view="threads" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} /> <Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} /> <Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} /> <Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />

View file

@ -16,7 +16,8 @@ export interface UserInputRequest {
export interface ChatbotWorkflow { export interface ChatbotWorkflow {
id: string; id: string;
mandateId: string; mandateId?: string; // Optional - not in ChatbotConversation
featureInstanceId?: string; // From ChatbotConversation
status: string; status: string;
name?: string; name?: string;
currentRound?: number; currentRound?: number;
@ -254,7 +255,7 @@ export async function getChatbotThreadsApi(
return { return {
items: Array.isArray(data.items) ? data.items : [], items: Array.isArray(data.items) ? data.items : [],
metadata: data.metadata || {} metadata: data.pagination ?? data.metadata ?? {}
}; };
} }

240
src/api/chatbotV2Api.ts Normal file
View file

@ -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<any>) => Promise<any>;
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<UploadChatbotV2Response> {
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<void> {
try {
const url = `/api/chatbotv2/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`;
const baseURL = api.defaults.baseURL || '';
const fullURL = baseURL + url;
const headers: Record<string, string> = { '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<ChatbotV2Workflow> {
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<void> {
await request({
url: `/api/chatbotv2/${instanceId}/conversations/${workflowId}`,
method: 'delete'
});
}

View file

@ -22,7 +22,8 @@ export interface MessageDocument {
*/ */
export interface Message { export interface Message {
id: string; id: string;
workflowId: string; workflowId?: string; // Legacy / backward compat
conversationId?: string; // New - from ChatbotMessage
parentMessageId?: string; parentMessageId?: string;
documents?: MessageDocument[]; documents?: MessageDocument[];
documentsLabel?: string; documentsLabel?: string;
@ -43,6 +44,13 @@ export interface Message {
actionProgress?: string; 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 * Message display variant
*/ */

View file

@ -106,7 +106,13 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.chatplayground': <FaPlay />, 'feature.chatplayground': <FaPlay />,
'feature.codeeditor': <FaFileAlt />, 'feature.codeeditor': <FaFileAlt />,
'feature.automation': <FaCogs />, 'feature.automation': <FaCogs />,
'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />, 'feature.chatbot': <FaComments />,
'page.feature.chatbotv2.conversations': <FaComments />,
'page.feature.chatbotv2.upload': <FaComments />,
'page.feature.chatbotv2.chat': <FaComments />,
'page.feature.chatbotv2.threads': <FaComments />,
'feature.chatbotv2': <FaComments />,
'feature.teamsbot': <FaHeadset />, 'feature.teamsbot': <FaHeadset />,
}; };

View file

@ -16,7 +16,7 @@ import {
type ChatDataItem, type ChatDataItem,
type StartChatbotRequest type StartChatbotRequest
} from '../api/chatbotApi'; } from '../api/chatbotApi';
import { Message } from '../components/UiComponents/Messages/MessagesTypes'; import { Message, getConversationId } from '../components/UiComponents/Messages/MessagesTypes';
import { useInstanceId } from './useCurrentInstance'; import { useInstanceId } from './useCurrentInstance';
export interface ChatbotHookReturn { export interface ChatbotHookReturn {
@ -170,7 +170,8 @@ export function useChatbot(): ChatbotHookReturn {
const tempUserMessageId = `temp-user-${Date.now()}`; const tempUserMessageId = `temp-user-${Date.now()}`;
const userMessage: Message = { const userMessage: Message = {
id: tempUserMessageId, id: tempUserMessageId,
workflowId: currentWorkflowId || '', workflowId: currentWorkflowId || undefined,
conversationId: currentWorkflowId || undefined,
role: 'user', role: 'user',
message: inputMessageContent, message: inputMessageContent,
publishedAt: Date.now() publishedAt: Date.now()
@ -211,7 +212,7 @@ export function useChatbot(): ChatbotHookReturn {
return; return;
} }
// Handle workflow update // Handle workflow update (includes name updates from background task)
if (item.type === 'stat' && item.item?.id) { if (item.type === 'stat' && item.item?.id) {
newWorkflowId = item.item.id; newWorkflowId = item.item.id;
setCurrentWorkflowId(item.item.id); setCurrentWorkflowId(item.item.id);
@ -223,15 +224,19 @@ export function useChatbot(): ChatbotHookReturn {
console.log('Workflow status is stopped'); console.log('Workflow status is stopped');
setIsStreaming(false); setIsStreaming(false);
} }
// Refresh threads when workflow data arrives (e.g. name update from background)
if (item.item?.name) {
refreshThreads();
}
} }
// Handle messages // Handle messages
if (item.type === 'message' && item.item) { if (item.type === 'message' && item.item) {
const message = item.item as Message; const message = item.item as Message;
// Extract workflowId from message if available and not yet set // Extract conversation/workflow ID from message (supports workflowId and conversationId)
if (message.workflowId) { const extractedWorkflowId = getConversationId(message);
const extractedWorkflowId = message.workflowId; if (extractedWorkflowId) {
// Update local variable and state if not already set // Update local variable and state if not already set
if (!newWorkflowId) { if (!newWorkflowId) {
newWorkflowId = extractedWorkflowId; newWorkflowId = extractedWorkflowId;
@ -334,10 +339,10 @@ export function useChatbot(): ChatbotHookReturn {
// Try to get workflowId from currentWorkflowId, or from the latest message // Try to get workflowId from currentWorkflowId, or from the latest message
let workflowIdToStop = currentWorkflowId; let workflowIdToStop = currentWorkflowId;
if (!workflowIdToStop && messages.length > 0) { 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]; const latestMessage = messages[messages.length - 1];
if (latestMessage.workflowId) { workflowIdToStop = getConversationId(latestMessage) || undefined;
workflowIdToStop = latestMessage.workflowId; if (workflowIdToStop) {
console.log('Extracted workflowId from latest message:', workflowIdToStop); console.log('Extracted workflowId from latest message:', workflowIdToStop);
} }
} }

326
src/hooks/useChatbotV2.ts Normal file
View file

@ -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<void>;
addExistingFile: (id: string, fileName: string) => void;
submitContext: () => Promise<string | null>; // Returns conversationId or null
clearPendingFiles: () => void;
removePendingFile: (id: string) => void;
// Actions
selectThread: (workflowId: string) => Promise<void>;
createNewThread: () => void;
sendMessage: (input: string) => Promise<void>;
stopStreaming: () => Promise<void>;
deleteThread: (workflowId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
// 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<ChatbotV2Workflow[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [loadingThreads, setLoadingThreads] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingStatus, setStreamingStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [uploadingContext, setUploadingContext] = useState(false);
const [pendingFiles, setPendingFiles] = useState<Array<{ id: string; name: string }>>([]);
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<string | null> => {
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
};
}

View file

@ -23,6 +23,9 @@ import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccounting
// Chatbot Views // Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView'; import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
// Chatbot V2 Views
import { ChatbotV2ConversationsView } from './views/chatbotV2/ChatbotV2ConversationsView';
// RealEstate Views // RealEstate Views
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
@ -116,6 +119,13 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
conversations: ChatbotConversationsView, conversations: ChatbotConversationsView,
settings: ChatbotSettings, settings: ChatbotSettings,
}, },
chatbotv2: {
dashboard: ChatbotV2ConversationsView,
conversations: ChatbotV2ConversationsView,
upload: ChatbotV2ConversationsView,
chat: ChatbotV2ConversationsView,
threads: ChatbotV2ConversationsView,
},
realestate: { realestate: {
dashboard: RealEstatePekView, dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder, 'instance-roles': RealEstateInstanceRolesPlaceholder,

View file

@ -54,6 +54,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [chatbotConnectors, setChatbotConnectors] = useState<string[]>(['preprocessor']); // Array for multiselect (database connectors only) const [chatbotConnectors, setChatbotConnectors] = useState<string[]>(['preprocessor']); // Array for multiselect (database connectors only)
const [chatbotSystemPrompt, setChatbotSystemPrompt] = useState<string>(''); const [chatbotSystemPrompt, setChatbotSystemPrompt] = useState<string>('');
const [chatbotEnableWebResearch, setChatbotEnableWebResearch] = useState<boolean>(true); // Enable Tavily web research const [chatbotEnableWebResearch, setChatbotEnableWebResearch] = useState<boolean>(true); // Enable Tavily web research
const [chatbotAllowedProviders, setChatbotAllowedProviders] = useState<string[]>([]); // Allowed LLM providers (empty = all)
// Ref to track form data for featureCode detection // Ref to track form data for featureCode detection
const formDataRef = useRef<Record<string, any>>({}); const formDataRef = useRef<Record<string, any>>({});
@ -128,23 +129,15 @@ export const AdminFeatureAccessPage: React.FC = () => {
setIsSubmitting(false); setIsSubmitting(false);
return; return;
} }
if (chatbotConnectors.length === 0) { const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null;
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';
config = { config = {
connector: { connector: chatbotConnectors.length > 0 ? {
types: chatbotConnectors.length > 0 ? chatbotConnectors : ['preprocessor'], // Array of selected connectors types: chatbotConnectors,
type: primaryConnector, // Primary connector (for backward compatibility) type: primaryConnector,
customConnectorClass: null customConnectorClass: null
}, } : undefined,
prompts: { prompts: {
useCustomPrompts: true, // Always true since system prompt is required useCustomPrompts: true,
customAnalysisPrompt: chatbotSystemPrompt, customAnalysisPrompt: chatbotSystemPrompt,
customFinalAnswerPrompt: chatbotSystemPrompt customFinalAnswerPrompt: chatbotSystemPrompt
}, },
@ -153,7 +146,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
enableWebResearch: chatbotEnableWebResearch, enableWebResearch: chatbotEnableWebResearch,
enableRetryOnEmpty: true, enableRetryOnEmpty: true,
maxRetryAttempts: 2 maxRetryAttempts: 2
} },
allowedProviders: chatbotAllowedProviders
}; };
} }
@ -172,6 +166,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotConnectors(['preprocessor']); setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt(''); setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true); setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId); fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache loadFeatures(); // Refresh global navigation cache
showSuccess('Feature-Instanz erstellt', `Die Instanz "${createLabel}" wurde erfolgreich erstellt.`); 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) // Filter out 'websearch' if it exists (legacy)
const connectorTypes = (config?.connector?.types || (config?.connector?.type ? [config.connector.type] : ['preprocessor'])) const connectorTypes = (config?.connector?.types || (config?.connector?.type ? [config.connector.type] : ['preprocessor']))
.filter((c: string) => c !== 'websearch'); // Remove websearch from connectors .filter((c: string) => c !== 'websearch'); // Remove websearch from connectors
setChatbotConnectors(connectorTypes.length > 0 ? connectorTypes : ['preprocessor']); setChatbotConnectors(connectorTypes);
setChatbotSystemPrompt(config?.prompts?.customAnalysisPrompt || config?.prompts?.customFinalAnswerPrompt || ''); 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 { } else {
setChatbotConnectors(['preprocessor']); setChatbotConnectors([]);
setChatbotSystemPrompt(''); setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true); setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
} }
setShowEditModal(true); setShowEditModal(true);
}; };
@ -227,23 +225,15 @@ export const AdminFeatureAccessPage: React.FC = () => {
setIsSubmitting(false); setIsSubmitting(false);
return; 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 || {}; const existingConfig = editingInstance.config as any || {};
// Use first connector as primary type (for backward compatibility) const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : null;
const primaryConnector = chatbotConnectors.length > 0 ? chatbotConnectors[0] : 'preprocessor';
config = { config = {
...existingConfig, ...existingConfig,
connector: { connector: chatbotConnectors.length > 0 ? {
types: chatbotConnectors.length > 0 ? chatbotConnectors : ['preprocessor'], // Array of selected connectors types: chatbotConnectors,
type: primaryConnector, // Primary connector (for backward compatibility) type: primaryConnector,
customConnectorClass: existingConfig.connector?.customConnectorClass || null customConnectorClass: existingConfig.connector?.customConnectorClass || null
}, } : undefined,
prompts: { prompts: {
useCustomPrompts: true, // Always true since system prompt is required useCustomPrompts: true, // Always true since system prompt is required
customAnalysisPrompt: chatbotSystemPrompt, customAnalysisPrompt: chatbotSystemPrompt,
@ -255,7 +245,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
enableWebResearch: chatbotEnableWebResearch, enableWebResearch: chatbotEnableWebResearch,
enableRetryOnEmpty: existingConfig.behavior?.enableRetryOnEmpty !== false, enableRetryOnEmpty: existingConfig.behavior?.enableRetryOnEmpty !== false,
maxRetryAttempts: existingConfig.behavior?.maxRetryAttempts || 2 maxRetryAttempts: existingConfig.behavior?.maxRetryAttempts || 2
} },
allowedProviders: chatbotAllowedProviders
}; };
} }
@ -269,6 +260,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
setEditingInstance(null); setEditingInstance(null);
setChatbotConnectors(['preprocessor']); setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt(''); setChatbotSystemPrompt('');
setChatbotAllowedProviders([]);
fetchInstances(selectedMandateId); fetchInstances(selectedMandateId);
loadFeatures(); // Refresh global navigation cache loadFeatures(); // Refresh global navigation cache
showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`); showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
@ -547,6 +539,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotConnectors(['preprocessor']); setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt(''); setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true); setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}} }}
placeholder="Feature auswählen (erforderlich)" placeholder="Feature auswählen (erforderlich)"
className={styles.configSelect} className={styles.configSelect}
@ -589,9 +582,11 @@ export const AdminFeatureAccessPage: React.FC = () => {
connectors={chatbotConnectors} connectors={chatbotConnectors}
systemPrompt={chatbotSystemPrompt} systemPrompt={chatbotSystemPrompt}
enableWebResearch={chatbotEnableWebResearch} enableWebResearch={chatbotEnableWebResearch}
allowedProviders={chatbotAllowedProviders}
onConnectorsChange={setChatbotConnectors} onConnectorsChange={setChatbotConnectors}
onSystemPromptChange={setChatbotSystemPrompt} onSystemPromptChange={setChatbotSystemPrompt}
onEnableWebResearchChange={setChatbotEnableWebResearch} onEnableWebResearchChange={setChatbotEnableWebResearch}
onAllowedProvidersChange={setChatbotAllowedProviders}
/> />
)} )}
@ -610,6 +605,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotConnectors(['preprocessor']); setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt(''); setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true); setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}} }}
submitButtonText="Erstellen" submitButtonText="Erstellen"
cancelButtonText="Abbrechen" cancelButtonText="Abbrechen"
@ -663,6 +659,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
setChatbotConnectors(['preprocessor']); setChatbotConnectors(['preprocessor']);
setChatbotSystemPrompt(''); setChatbotSystemPrompt('');
setChatbotEnableWebResearch(true); setChatbotEnableWebResearch(true);
setChatbotAllowedProviders([]);
}} }}
submitButtonText="Speichern" submitButtonText="Speichern"
cancelButtonText="Abbrechen" cancelButtonText="Abbrechen"
@ -674,9 +671,11 @@ export const AdminFeatureAccessPage: React.FC = () => {
connectors={chatbotConnectors} connectors={chatbotConnectors}
systemPrompt={chatbotSystemPrompt} systemPrompt={chatbotSystemPrompt}
enableWebResearch={chatbotEnableWebResearch} enableWebResearch={chatbotEnableWebResearch}
allowedProviders={chatbotAllowedProviders}
onConnectorsChange={setChatbotConnectors} onConnectorsChange={setChatbotConnectors}
onSystemPromptChange={setChatbotSystemPrompt} onSystemPromptChange={setChatbotSystemPrompt}
onEnableWebResearchChange={setChatbotEnableWebResearch} onEnableWebResearchChange={setChatbotEnableWebResearch}
onAllowedProvidersChange={setChatbotAllowedProviders}
/> />
)} )}
</div> </div>

View file

@ -5,10 +5,20 @@
* Only shown when featureCode is "chatbot" * Only shown when featureCode is "chatbot"
*/ */
import React from 'react'; import React, { useEffect } from 'react';
import { TextField } from '../../components/UiComponents/TextField'; import { TextField } from '../../components/UiComponents/TextField';
import { useBilling } from '../../hooks/useBilling';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
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 { export interface ChatbotConfig {
connector: string; connector: string;
systemPrompt: string; systemPrompt: string;
@ -18,33 +28,51 @@ export interface ChatbotConfigSectionProps {
connectors: string[]; // Array of selected connector types (database connectors only) connectors: string[]; // Array of selected connector types (database connectors only)
systemPrompt: string; systemPrompt: string;
enableWebResearch: boolean; // Enable Tavily web research enableWebResearch: boolean; // Enable Tavily web research
allowedProviders: string[]; // Selected LLM providers (empty = all allowed)
onConnectorsChange: (connectors: string[]) => void; onConnectorsChange: (connectors: string[]) => void;
onSystemPromptChange: (prompt: string) => void; onSystemPromptChange: (prompt: string) => void;
onEnableWebResearchChange: (enabled: boolean) => void; onEnableWebResearchChange: (enabled: boolean) => void;
onAllowedProvidersChange: (providers: string[]) => void;
} }
export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
connectors, connectors,
systemPrompt, systemPrompt,
enableWebResearch, enableWebResearch,
allowedProviders,
onConnectorsChange, onConnectorsChange,
onSystemPromptChange, onSystemPromptChange,
onEnableWebResearchChange onEnableWebResearchChange,
onAllowedProvidersChange,
}) => { }) => {
const { allowedProviders: availableProviders, loadAllowedProviders, loading: providersLoading } = useBilling();
useEffect(() => {
if (availableProviders.length === 0 && !providersLoading) {
loadAllowedProviders();
}
}, []);
const availableConnectors = [ const availableConnectors = [
{ id: 'preprocessor', label: 'Althaus Preprocessor', value: 'preprocessor' } { id: 'preprocessor', label: 'Althaus Preprocessor', value: 'preprocessor' }
]; ];
const handleConnectorToggle = (connectorValue: string) => { const handleConnectorToggle = (connectorValue: string) => {
if (connectors.includes(connectorValue)) { if (connectors.includes(connectorValue)) {
// Remove connector
onConnectorsChange(connectors.filter(c => c !== connectorValue)); onConnectorsChange(connectors.filter(c => c !== connectorValue));
} else { } else {
// Add connector
onConnectorsChange([...connectors, connectorValue]); onConnectorsChange([...connectors, connectorValue]);
} }
}; };
const handleProviderToggle = (provider: string) => {
if (allowedProviders.includes(provider)) {
onAllowedProvidersChange(allowedProviders.filter(p => p !== provider));
} else {
onAllowedProvidersChange([...allowedProviders, provider]);
}
};
return ( return (
<div className={styles.chatbotConfigSection}> <div className={styles.chatbotConfigSection}>
<div className={styles.configField}> <div className={styles.configField}>
@ -69,7 +97,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
</div> </div>
{connectors.length === 0 && ( {connectors.length === 0 && (
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}> <p style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.5rem' }}>
Bitte wählen Sie mindestens einen Connector aus. Ohne Connector werden keine SQL-Abfragen unterstützt.
</p> </p>
)} )}
</div> </div>
@ -89,6 +117,36 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
</p> </p>
</div> </div>
<div className={styles.configField}>
<label className={styles.configLabel}>
LLM-Anbieter:
</label>
<div className={styles.multiselectContainer}>
{providersLoading ? (
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>Lade Anbieter...</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' }}>
Keine Einschränkung alle verfügbaren Anbieter werden verwendet.
</p>
)}
</div>
<div className={styles.configField}> <div className={styles.configField}>
<label className={styles.configLabel}> <label className={styles.configLabel}>
System Prompt: <span style={{ color: 'var(--error-color)' }}>*</span> System Prompt: <span style={{ color: 'var(--error-color)' }}>*</span>

View file

@ -0,0 +1,448 @@
/**
* ChatbotV2ConversationsView
*
* Context-aware chat: first "Add context" (upload files, extract), then chat.
* Chat history on the left like the original chatbot.
*/
import React, { useState, useRef, useCallback } from 'react';
import { useChatbotV2 } from '../../../hooks/useChatbotV2';
import { useApiRequest } from '../../../hooks/useApi';
import { fetchFiles, type FileInfo } from '../../../api/fileApi';
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, LuUpload, LuFileText, LuX, LuFolderOpen, LuPlus } from 'react-icons/lu';
import messagesStyles from '../../../components/UiComponents/Messages/Messages.module.css';
import styles from './ChatbotV2Views.module.css';
export const ChatbotV2ConversationsView: React.FC = () => {
const {
threads,
selectedThreadId,
loadingThreads,
error,
messages,
loadingMessages,
selectedThread,
isStreaming,
streamingStatus,
uploadingContext,
pendingFiles,
addContextFiles,
addExistingFile,
submitContext,
clearPendingFiles,
removePendingFile,
selectThread,
createNewThread,
sendMessage,
stopStreaming,
deleteThread,
refreshThreads,
inputValue,
setInputValue
} = useChatbotV2();
const { request } = useApiRequest();
const [deletingId, setDeletingId] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [showExistingPicker, setShowExistingPicker] = useState(false);
const [existingFiles, setExistingFiles] = useState<FileInfo[]>([]);
const [loadingExistingFiles, setLoadingExistingFiles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadExistingFiles = useCallback(async () => {
setLoadingExistingFiles(true);
try {
const data = await fetchFiles(request);
const items = Array.isArray(data) ? data : (data?.items ?? []);
setExistingFiles(items);
setShowExistingPicker(true);
} catch (err) {
console.error('Fehler beim Laden der Dateien:', err);
} finally {
setLoadingExistingFiles(false);
}
}, [request]);
const handleAddExistingFile = useCallback(
(file: FileInfo) => {
addExistingFile(file.id, file.fileName);
},
[addExistingFile]
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!inputValue.trim() || isStreaming) return;
await sendMessage(inputValue);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!inputValue.trim() || isStreaming) return;
sendMessage(inputValue);
}
};
const handleStop = async () => {
if (isStreaming) {
try {
await stopStreaming();
} catch (err) {
console.error('Error stopping:', err);
}
}
};
const handleDeleteThread = async (e: React.MouseEvent, workflowId: string) => {
e.stopPropagation();
if (window.confirm('Möchten Sie diese Konversation wirklich löschen?')) {
setDeletingId(workflowId);
try {
await deleteThread(workflowId);
} finally {
setDeletingId(null);
}
}
};
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
addContextFiles(Array.from(files));
}
if (fileInputRef.current) fileInputRef.current.value = '';
},
[addContextFiles]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (files && files.length > 0) {
addContextFiles(Array.from(files));
}
},
[addContextFiles]
);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => setIsDragOver(false);
const handleExtractClick = async () => {
await submitContext();
};
const formatDate = (timestamp?: number) => {
if (!timestamp) return '';
const ms = timestamp * 1000;
const date = new Date(ms);
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 'Gerade eben';
if (diffMins < 60) return `Vor ${diffMins} Min`;
if (diffHours < 24) return `Vor ${diffHours} Std`;
if (diffDays < 7) return `Vor ${diffDays} Tagen`;
const { time } = formatUnixTimestamp(timestamp, 'de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
return time;
};
const getThreadTitle = (thread: any) => thread.name || 'Kontext-Chat';
const showExtracting = selectedThreadId && selectedThread?.status === 'extracting';
const showChat = selectedThreadId && selectedThread && (selectedThread.status === 'ready' || selectedThread.status === 'running');
const showAddContext = !selectedThreadId;
return (
<div className={styles.chatbotView}>
{/* Chat History Sidebar */}
<aside className={styles.chatHistory}>
<div className={styles.chatHistoryHeader}>
<h2 className={styles.chatHistoryTitle}>Konversationen</h2>
<button className={styles.newChatButton} onClick={createNewThread} title="Neue Konversation">
<LuMessageSquare /> Neu
</button>
</div>
{loadingThreads ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<span>Lade Konversationen...</span>
</div>
) : error ? (
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={refreshThreads}>
Erneut versuchen
</button>
</div>
) : threads.length === 0 ? (
<div className={styles.emptyState}>
<LuMessageSquare className={styles.emptyIcon} />
<p>Noch keine Konversationen.</p>
<p className={styles.emptyHint}>Klicke auf Neu und lade Dateien als Kontext hoch.</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}>
{thread.status === 'ready' || thread.status === 'running'
? formatDate(thread.lastActivity || thread.startedAt)
: thread.status === 'extracting'
? 'Wird extrahiert...'
: formatDate(thread.lastActivity)}
</div>
</div>
<button
className={styles.deleteButton}
onClick={(e) => handleDeleteThread(e, thread.id)}
disabled={deletingId === thread.id}
title="Löschen"
>
{deletingId === thread.id ? <div className={styles.spinner} /> : <LuTrash2 />}
</button>
</div>
))}
</div>
)}
</aside>
{/* Main Area: Add Context, Extracting, or Chat */}
<main className={styles.chatArea}>
{showExtracting ? (
<div className={styles.extractingPhase}>
<div className={styles.spinner} />
<span>Kontext wird extrahiert...</span>
<p className={styles.extractingHint}>Bitte kurz warten.</p>
</div>
) : showAddContext ? (
/* Add Context Phase */
<div className={styles.addContextPhase}>
<div className={styles.addContextHeader}>
<LuFileText className={styles.addContextIcon} />
<h3 className={styles.addContextTitle}>Kontext hinzufügen</h3>
<p className={styles.addContextHint}>
Lade PDF- oder Textdateien hoch. Sie werden analysiert und als Kontext für den Chat verwendet.
</p>
</div>
<div
className={`${styles.dropZone} ${isDragOver ? styles.dropZoneActive : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.txt,application/pdf,text/plain"
onChange={handleFileSelect}
className={styles.hiddenInput}
/>
<LuUpload className={styles.dropZoneIcon} />
<p>Dateien hier ablegen oder klicken zum Auswählen</p>
<p className={styles.dropZoneHint}>PDF und TXT unterstützt</p>
</div>
<div className={styles.existingFilesSection}>
<Button
variant="secondary"
size="md"
onClick={loadExistingFiles}
disabled={loadingExistingFiles}
icon={LuFolderOpen}
>
{loadingExistingFiles ? 'Lade...' : 'Aus bereits hochgeladenen Dateien wählen'}
</Button>
{showExistingPicker && (
<div className={styles.existingFilesList}>
{existingFiles.length === 0 ? (
<p className={styles.existingFilesEmpty}>Keine Dateien vorhanden. Lade zuerst Dateien hoch.</p>
) : (
<>
<div className={styles.existingFilesHeader}>
<span>Deine Dateien</span>
<button
type="button"
className={styles.closeExistingBtn}
onClick={() => setShowExistingPicker(false)}
title="Schließen"
>
<LuX />
</button>
</div>
<ul className={styles.existingFilesListInner}>
{existingFiles.map((file) => {
const isPending = pendingFiles.some((pf) => pf.id === file.id);
return (
<li key={file.id} className={styles.existingFileItem}>
<LuFileText />
<span className={styles.existingFileName}>{file.fileName}</span>
<Button
variant="primary"
size="sm"
onClick={() => handleAddExistingFile(file)}
disabled={isPending}
icon={LuPlus}
>
{isPending ? 'Hinzugefügt' : 'Hinzufügen'}
</Button>
</li>
);
})}
</ul>
</>
)}
</div>
)}
</div>
{pendingFiles.length > 0 && (
<div className={styles.pendingFilesSection}>
<h4>Ausgewählte Dateien ({pendingFiles.length})</h4>
<ul className={styles.pendingFilesList}>
{pendingFiles.map((f) => (
<li key={f.id} className={styles.pendingFileItem}>
<LuFileText />
<span>{f.name}</span>
<button
type="button"
className={styles.removeFileBtn}
onClick={(e) => {
e.stopPropagation();
removePendingFile(f.id);
}}
title="Entfernen"
>
<LuX />
</button>
</li>
))}
</ul>
<div className={styles.extractActions}>
<Button
variant="secondary"
size="md"
onClick={clearPendingFiles}
disabled={uploadingContext}
>
Zurücksetzen
</Button>
<Button
variant="primary"
size="md"
onClick={handleExtractClick}
disabled={uploadingContext || pendingFiles.length === 0}
icon={LuUpload}
>
{uploadingContext ? 'Extrahieren läuft...' : 'Kontext extrahieren & Chat starten'}
</Button>
</div>
</div>
)}
</div>
) : (
/* Chat Phase */
<>
<div className={styles.messagesArea}>
{loadingMessages && messages.length === 0 ? (
<div className={styles.loading}>
<div className={styles.spinner} />
<span>Lade Nachrichten...</span>
</div>
) : messages.length === 0 ? (
<div className={`${messagesStyles.messagesContainer} ${messagesStyles.emptyContainer}`}>
<div className={messagesStyles.emptyState}>
Stelle Fragen zu deinen Dokumenten. Der Kontext wurde bereits extrahiert.
</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>
<form onSubmit={handleSubmit} className={styles.inputForm}>
<TextField
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
placeholder="Frage zu deinen Dokumenten..."
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>
</div>
);
};
export default ChatbotV2ConversationsView;

View file

@ -0,0 +1,619 @@
/**
* Chatbot V2 Views Styles
* Add context + Chat with history on left
*/
.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;
}
/* =============================================================================
* Add Context Phase
* ============================================================================= */
.addContextPhase {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 2rem;
overflow-y: auto;
}
.addContextHeader {
text-align: center;
margin-bottom: 2rem;
}
.addContextIcon {
font-size: 3rem;
color: var(--primary-color, #2563eb);
margin-bottom: 1rem;
}
.addContextTitle {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.addContextHint {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary, #666);
}
.dropZone {
width: 100%;
max-width: 480px;
padding: 3rem 2rem;
border: 2px dashed var(--border-color, #e0e0e0);
border-radius: 12px;
background: var(--surface-color, #f8f9fa);
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.dropZone:hover,
.dropZoneActive {
border-color: var(--primary-color, #2563eb);
background: var(--primary-light, #eff6ff);
}
.dropZoneIcon {
font-size: 2.5rem;
color: var(--text-tertiary, #999);
}
.dropZone p {
margin: 0;
font-size: 0.9375rem;
color: var(--text-primary, #1a1a1a);
}
.dropZoneHint {
font-size: 0.75rem !important;
color: var(--text-tertiary, #888) !important;
}
.hiddenInput {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
/* =============================================================================
* Existing Files Picker
* ============================================================================= */
.existingFilesSection {
width: 100%;
max-width: 480px;
margin-top: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.existingFilesList {
width: 100%;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--bg-primary, #ffffff);
max-height: 240px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.existingFilesHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.closeExistingBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #666);
cursor: pointer;
transition: all 0.2s;
}
.closeExistingBtn:hover {
background: var(--error-light, #fee2e2);
color: var(--error-color, #dc2626);
}
.existingFilesListInner {
list-style: none;
padding: 0;
margin: 0;
overflow-y: auto;
}
.existingFileItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 0.875rem;
}
.existingFileItem:last-child {
border-bottom: none;
}
.existingFileName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.existingFilesEmpty {
padding: 1.5rem;
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary, #666);
text-align: center;
}
.pendingFilesSection {
width: 100%;
max-width: 480px;
margin-top: 2rem;
}
.pendingFilesSection h4 {
margin: 0 0 0.75rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.pendingFilesList {
list-style: none;
padding: 0;
margin: 0 0 1rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
overflow: hidden;
}
.pendingFileItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--bg-primary, #ffffff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
font-size: 0.875rem;
}
.pendingFileItem:last-child {
border-bottom: none;
}
.pendingFileItem span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.removeFileBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary, #666);
cursor: pointer;
transition: all 0.2s;
}
.removeFileBtn:hover {
background: var(--error-light, #fee2e2);
color: var(--error-color, #dc2626);
}
.extractActions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
/* =============================================================================
* Extracting Phase
* ============================================================================= */
.extractingPhase {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--text-secondary, #666);
}
.extractingHint {
margin: 0;
font-size: 0.875rem;
color: var(--text-tertiary, #888);
}
/* =============================================================================
* Typing Indicator & Streaming
* ============================================================================= */
.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; }
}
.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;
}
.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) .addContextTitle { color: var(--text-primary-dark, #ffffff); }
:global(.dark-theme) .addContextHint { color: var(--text-secondary-dark, #aaa); }
:global(.dark-theme) .dropZone { background: var(--surface-dark, #1a1a1a); border-color: var(--border-dark, #333); }
:global(.dark-theme) .dropZone:hover,
:global(.dark-theme) .dropZoneActive { border-color: var(--primary-color, #2563eb); background: var(--primary-dark, #1e3a8a); }
:global(.dark-theme) .dropZone p { color: var(--text-primary-dark, #ffffff); }
:global(.dark-theme) .existingFilesList { background: var(--surface-dark, #1a1a1a); border-color: var(--border-dark, #333); }
:global(.dark-theme) .existingFilesHeader { border-color: var(--border-dark, #333); color: var(--text-primary-dark, #ffffff); }
:global(.dark-theme) .existingFileItem { border-color: var(--border-dark, #333); }
:global(.dark-theme) .existingFilesEmpty { color: var(--text-secondary-dark, #aaa); }
:global(.dark-theme) .pendingFileItem { background: var(--surface-dark, #1a1a1a); border-color: var(--border-dark, #333); }
:global(.dark-theme) .pendingFilesSection h4 { color: var(--text-primary-dark, #ffffff); }
: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) .typingBubble { background-color: var(--surface-dark, #2a2a2a); }
:global(.dark-theme) .typingDots span { background-color: var(--text-secondary-dark, #aaa); }
:global(.dark-theme) .statusText { color: var(--text-secondary-dark, #aaa); }

View file

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

View file

@ -232,6 +232,17 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
{ code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' }, { code: 'settings', label: { de: 'Einstellungen', en: 'Settings' }, path: 'settings' },
] ]
}, },
chatbotv2: {
code: 'chatbotv2',
label: { de: 'Chatbot V2', en: 'Chatbot V2' },
icon: 'chat',
views: [
{ code: 'conversations', label: { de: 'Konversationen', en: 'Conversations' }, path: 'conversations' },
{ code: 'upload', label: { de: 'Upload & Extrahieren', en: 'Upload & Extract' }, path: 'upload' },
{ code: 'chat', label: { de: 'Chat', en: 'Chat' }, path: 'chat' },
{ code: 'threads', label: { de: 'Konversationen', en: 'Threads' }, path: 'threads' },
]
},
realestate: { realestate: {
code: 'realestate', code: 'realestate',
label: { de: 'Immobilien', en: 'Real Estate' }, label: { de: 'Immobilien', en: 'Real Estate' },