nicht fertig; Stand Demo Kessler
This commit is contained in:
parent
39e74110cd
commit
f0a7daea02
14 changed files with 1789 additions and 50 deletions
|
|
@ -151,6 +151,9 @@ function App() {
|
|||
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||
<Route path="files" element={<FeatureViewPage view="files" />} />
|
||||
<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="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||
|
|
|
|||
|
|
@ -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 ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
240
src/api/chatbotV2Api.ts
Normal file
240
src/api/chatbotV2Api.ts
Normal 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'
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -106,7 +106,13 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'feature.chatplayground': <FaPlay />,
|
||||
'feature.codeeditor': <FaFileAlt />,
|
||||
'feature.automation': <FaCogs />,
|
||||
'page.feature.chatbot.conversations': <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 />,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
326
src/hooks/useChatbotV2.ts
Normal file
326
src/hooks/useChatbotV2.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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<string, Record<string, ViewComponent>> = {
|
|||
conversations: ChatbotConversationsView,
|
||||
settings: ChatbotSettings,
|
||||
},
|
||||
chatbotv2: {
|
||||
dashboard: ChatbotV2ConversationsView,
|
||||
conversations: ChatbotV2ConversationsView,
|
||||
upload: ChatbotV2ConversationsView,
|
||||
chat: ChatbotV2ConversationsView,
|
||||
threads: ChatbotV2ConversationsView,
|
||||
},
|
||||
realestate: {
|
||||
dashboard: RealEstatePekView,
|
||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
|||
const [chatbotConnectors, setChatbotConnectors] = useState<string[]>(['preprocessor']); // Array for multiselect (database connectors only)
|
||||
const [chatbotSystemPrompt, setChatbotSystemPrompt] = useState<string>('');
|
||||
const [chatbotEnableWebResearch, setChatbotEnableWebResearch] = useState<boolean>(true); // Enable Tavily web research
|
||||
const [chatbotAllowedProviders, setChatbotAllowedProviders] = useState<string[]>([]); // Allowed LLM providers (empty = all)
|
||||
|
||||
// Ref to track form data for featureCode detection
|
||||
const formDataRef = useRef<Record<string, any>>({});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
anthropic: 'Anthropic (Claude)',
|
||||
openai: 'OpenAI (GPT)',
|
||||
perplexity: 'Perplexity',
|
||||
tavily: 'Tavily (Web Search)',
|
||||
privatellm: 'Private LLM',
|
||||
internal: 'Internal',
|
||||
};
|
||||
|
||||
export interface ChatbotConfig {
|
||||
connector: string;
|
||||
systemPrompt: string;
|
||||
|
|
@ -18,33 +28,51 @@ export interface ChatbotConfigSectionProps {
|
|||
connectors: string[]; // Array of selected connector types (database connectors only)
|
||||
systemPrompt: string;
|
||||
enableWebResearch: boolean; // Enable Tavily web research
|
||||
allowedProviders: string[]; // Selected LLM providers (empty = all allowed)
|
||||
onConnectorsChange: (connectors: string[]) => void;
|
||||
onSystemPromptChange: (prompt: string) => void;
|
||||
onEnableWebResearchChange: (enabled: boolean) => void;
|
||||
onAllowedProvidersChange: (providers: string[]) => void;
|
||||
}
|
||||
|
||||
export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
|
||||
connectors,
|
||||
systemPrompt,
|
||||
enableWebResearch,
|
||||
allowedProviders,
|
||||
onConnectorsChange,
|
||||
onSystemPromptChange,
|
||||
onEnableWebResearchChange
|
||||
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 (
|
||||
<div className={styles.chatbotConfigSection}>
|
||||
<div className={styles.configField}>
|
||||
|
|
@ -69,7 +97,7 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
|
|||
</div>
|
||||
{connectors.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -89,6 +117,36 @@ export const ChatbotConfigSection: React.FC<ChatbotConfigSectionProps> = ({
|
|||
</p>
|
||||
</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}>
|
||||
<label className={styles.configLabel}>
|
||||
System Prompt: <span style={{ color: 'var(--error-color)' }}>*</span>
|
||||
|
|
|
|||
448
src/pages/views/chatbotV2/ChatbotV2ConversationsView.tsx
Normal file
448
src/pages/views/chatbotV2/ChatbotV2ConversationsView.tsx
Normal 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;
|
||||
619
src/pages/views/chatbotV2/ChatbotV2Views.module.css
Normal file
619
src/pages/views/chatbotV2/ChatbotV2Views.module.css
Normal 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); }
|
||||
5
src/pages/views/chatbotV2/index.ts
Normal file
5
src/pages/views/chatbotV2/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* Chatbot V2 Views Export
|
||||
*/
|
||||
|
||||
export { ChatbotV2ConversationsView } from './ChatbotV2ConversationsView';
|
||||
|
|
@ -232,6 +232,17 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
{ 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: {
|
||||
code: 'realestate',
|
||||
label: { de: 'Immobilien', en: 'Real Estate' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue