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="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" />} />
|
||||||
|
|
|
||||||
|
|
@ -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
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 {
|
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
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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 />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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
|
// 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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,32 +28,50 @@ 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}>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
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' },
|
{ 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' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue