AI Assistant: frontend - chat app, sidebar, settings with 4 tabs
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* AI Assistant plugin API client.
|
||||||
|
*
|
||||||
|
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
|
||||||
|
* AI Assistant plugin routes under `/ai/...`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiDelete, apiGet, apiPost, apiPut } from './client';
|
||||||
|
|
||||||
|
// ─── Types ───
|
||||||
|
|
||||||
|
export interface AIProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider_type: string;
|
||||||
|
api_key: string;
|
||||||
|
base_url: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_default: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIModel {
|
||||||
|
id: string;
|
||||||
|
provider_id: string;
|
||||||
|
model_id: string;
|
||||||
|
display_name: string;
|
||||||
|
context_window: number;
|
||||||
|
supports_tools: boolean;
|
||||||
|
supports_streaming: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIPreset {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
model_id: string;
|
||||||
|
provider_id: string | null;
|
||||||
|
temperature: number;
|
||||||
|
max_tokens: number;
|
||||||
|
top_p: number;
|
||||||
|
system_prompt: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
system_prompt: string;
|
||||||
|
preset_id: string | null;
|
||||||
|
tool_ids: string[];
|
||||||
|
is_default: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
agent_id: string | null;
|
||||||
|
title: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
is_sidebar: boolean;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
session_id: string;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
tool_calls?: Record<string, unknown>[];
|
||||||
|
tool_results?: Record<string, unknown>[];
|
||||||
|
tokens: number;
|
||||||
|
model_used: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AITool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>;
|
||||||
|
plugin_name: string;
|
||||||
|
required_permission: string | null;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Providers ───
|
||||||
|
|
||||||
|
export const fetchProviders = () => apiGet<AIProvider[]>('/ai/providers');
|
||||||
|
export const createProvider = (data: Partial<AIProvider>) => apiPost<AIProvider>('/ai/providers', data);
|
||||||
|
export const updateProvider = (id: string, data: Partial<AIProvider>) => apiPut<AIProvider>(`/ai/providers/${id}`, data);
|
||||||
|
export const deleteProvider = (id: string) => apiDelete(`/ai/providers/${id}`);
|
||||||
|
|
||||||
|
// ─── Models ───
|
||||||
|
|
||||||
|
export const fetchModels = (providerId?: string) =>
|
||||||
|
apiGet<AIModel[]>('/ai/models', { params: providerId ? { provider_id: providerId } : {} });
|
||||||
|
export const createModel = (data: Partial<AIModel>) => apiPost<AIModel>('/ai/models', data);
|
||||||
|
export const updateModel = (id: string, data: Partial<AIModel>) => apiPut<AIModel>(`/ai/models/${id}`, data);
|
||||||
|
export const deleteModel = (id: string) => apiDelete(`/ai/models/${id}`);
|
||||||
|
|
||||||
|
// ─── Presets ───
|
||||||
|
|
||||||
|
export const fetchPresets = () => apiGet<AIPreset[]>('/ai/presets');
|
||||||
|
export const createPreset = (data: Partial<AIPreset>) => apiPost<AIPreset>('/ai/presets', data);
|
||||||
|
export const updatePreset = (id: string, data: Partial<AIPreset>) => apiPut<AIPreset>(`/ai/presets/${id}`, data);
|
||||||
|
export const deletePreset = (id: string) => apiDelete(`/ai/presets/${id}`);
|
||||||
|
|
||||||
|
// ─── Agents ───
|
||||||
|
|
||||||
|
export const fetchAgents = () => apiGet<AIAgent[]>('/ai/agents');
|
||||||
|
export const createAgent = (data: Partial<AIAgent>) => apiPost<AIAgent>('/ai/agents', data);
|
||||||
|
export const updateAgent = (id: string, data: Partial<AIAgent>) => apiPut<AIAgent>(`/ai/agents/${id}`, data);
|
||||||
|
export const deleteAgent = (id: string) => apiDelete(`/ai/agents/${id}`);
|
||||||
|
|
||||||
|
// ─── Tools ───
|
||||||
|
|
||||||
|
export const fetchTools = () => apiGet<AITool[]>('/ai/tools');
|
||||||
|
|
||||||
|
// ─── Sessions ───
|
||||||
|
|
||||||
|
export const fetchSessions = (isSidebar?: boolean) =>
|
||||||
|
apiGet<ChatSession[]>('/ai/sessions', { params: isSidebar !== undefined ? { is_sidebar: isSidebar } : {} });
|
||||||
|
export const createSession = (data: { title?: string; agent_id?: string; is_sidebar?: boolean }) =>
|
||||||
|
apiPost<ChatSession>('/ai/sessions', data);
|
||||||
|
export const updateSession = (id: string, data: Partial<ChatSession>) =>
|
||||||
|
apiPut<ChatSession>(`/ai/sessions/${id}`, data);
|
||||||
|
export const deleteSession = (id: string) => apiDelete(`/ai/sessions/${id}`);
|
||||||
|
|
||||||
|
// ─── Messages ───
|
||||||
|
|
||||||
|
export const fetchMessages = (sessionId: string) =>
|
||||||
|
apiGet<ChatMessage[]>(`/ai/sessions/${sessionId}/messages`);
|
||||||
|
|
||||||
|
// ─── Streaming Chat ───
|
||||||
|
|
||||||
|
export interface StreamEvent {
|
||||||
|
type: 'token' | 'tool_calls' | 'tool_result' | 'done' | 'error';
|
||||||
|
content?: string;
|
||||||
|
tool?: string;
|
||||||
|
result?: string;
|
||||||
|
tools?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamChat(
|
||||||
|
sessionId: string,
|
||||||
|
content: string,
|
||||||
|
agentId?: string
|
||||||
|
): AsyncGenerator<StreamEvent> {
|
||||||
|
const response = await fetch(`/api/v1/ai/sessions/${sessionId}/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ content, agent_id: agentId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Stream failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error('No response body');
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
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: ')) {
|
||||||
|
const data = line.slice(6).trim();
|
||||||
|
if (data === '[DONE]') return;
|
||||||
|
try {
|
||||||
|
yield JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
// skip invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { streamChat, fetchMessages, type ChatMessage } from '@/api/ai';
|
||||||
|
|
||||||
|
interface ChatWindowProps {
|
||||||
|
sessionId: string;
|
||||||
|
agentId?: string | null;
|
||||||
|
className?: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatWindow({ sessionId, agentId, className, compact }: ChatWindowProps) {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [streamingContent, setStreamingContent] = useState('');
|
||||||
|
const [toolStatus, setToolStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
setMessages([]);
|
||||||
|
setError(null);
|
||||||
|
fetchMessages(sessionId)
|
||||||
|
.then((msgs) => setMessages(msgs))
|
||||||
|
.catch((e) => setError(e?.message || 'Failed to load messages'));
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages, streamingContent]);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const content = input.trim();
|
||||||
|
if (!content || isStreaming) return;
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
setIsStreaming(true);
|
||||||
|
setStreamingContent('');
|
||||||
|
setToolStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = {
|
||||||
|
id: 'temp-' + Date.now(),
|
||||||
|
session_id: sessionId,
|
||||||
|
role: 'user',
|
||||||
|
content,
|
||||||
|
tokens: 0,
|
||||||
|
model_used: '',
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMsg]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = streamChat(sessionId, content, agentId || undefined);
|
||||||
|
let accumulated = '';
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
if (event.type === 'token' && event.content) {
|
||||||
|
accumulated += event.content;
|
||||||
|
setStreamingContent(accumulated);
|
||||||
|
} else if (event.type === 'tool_calls' && event.tools) {
|
||||||
|
setToolStatus(`Tools: ${event.tools.join(', ')}`);
|
||||||
|
} else if (event.type === 'tool_result' && event.tool) {
|
||||||
|
setToolStatus(`Tool '${event.tool}' ausgefuehrt`);
|
||||||
|
} else if (event.type === 'done') {
|
||||||
|
setToolStatus(null);
|
||||||
|
const msgs = await fetchMessages(sessionId);
|
||||||
|
setMessages(msgs);
|
||||||
|
setStreamingContent('');
|
||||||
|
} else if (event.type === 'error') {
|
||||||
|
setError(event.content || 'Unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Stream failed');
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false);
|
||||||
|
setStreamingContent('');
|
||||||
|
setToolStatus(null);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex flex-col h-full bg-white', className)}>
|
||||||
|
<div ref={scrollRef} className={clsx('flex-1 overflow-y-auto', compact ? 'p-3' : 'p-6')}>
|
||||||
|
{messages.length === 0 && !streamingContent && !error && (
|
||||||
|
<div className="flex items-center justify-center h-full text-secondary-400 text-sm">
|
||||||
|
Starte eine Konversation...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div key={msg.id} className={clsx('mb-4 flex', msg.role === 'user' ? 'justify-end' : 'justify-start')}>
|
||||||
|
<div className={clsx('max-w-[80%] rounded-lg px-4 py-2 text-sm', msg.role === 'user' ? 'bg-primary-600 text-white' : 'bg-secondary-100 text-secondary-900')}>
|
||||||
|
<div className="whitespace-pre-wrap break-words">{msg.content}</div>
|
||||||
|
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs opacity-70">
|
||||||
|
Tools: {msg.tool_calls.map((tc: any) => tc.function?.name).join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streamingContent && (
|
||||||
|
<div className="mb-4 flex justify-start">
|
||||||
|
<div className="max-w-[80%] rounded-lg px-4 py-2 text-sm bg-secondary-100 text-secondary-900">
|
||||||
|
<div className="whitespace-pre-wrap break-words">{streamingContent}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toolStatus && (
|
||||||
|
<div className="mb-2 text-center text-xs text-secondary-500">⚙️ {toolStatus}</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<div className="rounded-lg px-4 py-2 text-sm bg-red-50 text-red-700">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={clsx('border-t border-secondary-200', compact ? 'p-2' : 'p-4')}>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isStreaming}
|
||||||
|
placeholder="Nachricht eingeben..."
|
||||||
|
rows={compact ? 1 : 2}
|
||||||
|
className={clsx('flex-1 resize-none rounded-lg border border-secondary-300 px-3 py-2 text-sm', 'focus:outline-none focus:ring-2 focus:ring-primary-500', 'disabled:opacity-50')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={isStreaming || !input.trim()}
|
||||||
|
className={clsx('rounded-lg px-4 py-2 text-sm font-medium text-white', 'bg-primary-600 hover:bg-primary-700', 'disabled:opacity-50 disabled:cursor-not-allowed')}
|
||||||
|
>
|
||||||
|
{isStreaming ? '...' : 'Senden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { fetchSessions, createSession, deleteSession, updateSession, type ChatSession } from '@/api/ai';
|
||||||
|
|
||||||
|
interface SessionListProps {
|
||||||
|
activeSessionId: string | null;
|
||||||
|
onSelectSession: (id: string) => void;
|
||||||
|
isSidebar?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) {
|
||||||
|
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadSessions = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await fetchSessions(isSidebar ? true : undefined);
|
||||||
|
setSessions(data);
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to load sessions');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, [isSidebar]);
|
||||||
|
|
||||||
|
const handleNewChat = async () => {
|
||||||
|
try {
|
||||||
|
const session = await createSession({ is_sidebar: isSidebar || false });
|
||||||
|
setSessions((prev) => [session, ...prev]);
|
||||||
|
onSelectSession(session.id);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to create session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await deleteSession(id);
|
||||||
|
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
if (activeSessionId === id) {
|
||||||
|
onSelectSession('');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Failed to delete session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-4 text-sm text-secondary-400">Laden...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('flex flex-col h-full', className)}>
|
||||||
|
<div className="p-3 border-b border-secondary-200">
|
||||||
|
<button
|
||||||
|
onClick={handleNewChat}
|
||||||
|
className="w-full rounded-lg bg-primary-600 text-white px-3 py-2 text-sm font-medium hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
+ Neuer Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="p-2 text-xs text-red-600">{error}</div>}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-secondary-400 text-center">Keine Chats vorhanden</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1 p-2">
|
||||||
|
{sessions.map((session) => (
|
||||||
|
<li key={session.id}>
|
||||||
|
<div
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
className={clsx(
|
||||||
|
'group flex items-center gap-2 rounded-lg px-3 py-2 text-sm cursor-pointer',
|
||||||
|
'hover:bg-secondary-100',
|
||||||
|
activeSessionId === session.id && 'bg-primary-100 text-primary-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{session.title}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDelete(session.id, e)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600"
|
||||||
|
title="Loeschen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ChatWindow } from '@/components/ai/ChatWindow';
|
||||||
|
import { createSession, fetchSessions } from '@/api/ai';
|
||||||
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
|
||||||
|
export function AISidebar() {
|
||||||
|
const { setAISidebarOpen } = useUIStore();
|
||||||
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Find or create a sidebar session
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const sessions = await fetchSessions(true);
|
||||||
|
if (sessions.length > 0) {
|
||||||
|
setSessionId(sessions[0].id);
|
||||||
|
} else {
|
||||||
|
const session = await createSession({ is_sidebar: true, title: 'Sidebar Chat' });
|
||||||
|
setSessionId(session.id);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AISidebar init error:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-80 flex-shrink-0 border-l border-secondary-200 bg-white flex flex-col h-full">
|
||||||
|
<div className="h-12 flex items-center justify-between px-3 border-b border-secondary-200">
|
||||||
|
<span className="text-sm font-medium text-secondary-700">KI Assistent</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setAISidebarOpen(false)}
|
||||||
|
className="text-secondary-400 hover:text-secondary-600"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-sm text-secondary-400">Laden...</div>
|
||||||
|
) : sessionId ? (
|
||||||
|
<ChatWindow sessionId={sessionId} compact className="h-full" />
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-sm text-red-500">
|
||||||
|
Session konnte nicht erstellt werden
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
import { PluginToolbar } from './PluginToolbar';
|
import { PluginToolbar } from './PluginToolbar';
|
||||||
|
import { AISidebar } from './AISidebar';
|
||||||
import { ToastContainer } from '@/components/ui/Toast';
|
import { ToastContainer } from '@/components/ui/Toast';
|
||||||
|
import { useUIStore } from '@/store/uiStore';
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
const { aiSidebarOpen } = useUIStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Hide AI sidebar on the AI Assistant page itself
|
||||||
|
const showAISidebar = aiSidebarOpen && !location.pathname.startsWith('/ai-assistant');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<PluginToolbar />
|
<PluginToolbar />
|
||||||
<main
|
<div className="flex-1 flex overflow-hidden">
|
||||||
className="flex-1 overflow-y-auto focus:outline-none"
|
<main
|
||||||
tabIndex={-1}
|
className="flex-1 overflow-y-auto focus:outline-none"
|
||||||
role="main"
|
tabIndex={-1}
|
||||||
id="main-content"
|
role="main"
|
||||||
data-testid="content-area"
|
id="main-content"
|
||||||
>
|
data-testid="content-area"
|
||||||
<Outlet />
|
>
|
||||||
</main>
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
{showAISidebar && <AISidebar />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const navItems: NavItem[] = [
|
|||||||
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
|
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
|
||||||
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
||||||
{ to: '/mail', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
{ to: '/mail', labelKey: 'nav.email', icon: navIcon('M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
||||||
|
{ to: '/ai-assistant', labelKey: 'nav.aiAssistant', icon: navIcon('M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z') },
|
||||||
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
|
{ to: '/audit-log', labelKey: 'nav.auditLog', icon: navIcon('M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function TopBar() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const { toggleSidebar } = useUIStore();
|
const { toggleSidebar, toggleAISidebar } = useUIStore();
|
||||||
const { currentTenant, availableTenants, switchTenant, isSwitching } = useTenant();
|
const { currentTenant, availableTenants, switchTenant, isSwitching } = useTenant();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
|
|
||||||
@@ -69,6 +69,18 @@ export function TopBar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* AI Assistant toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleAISidebar}
|
||||||
|
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||||
|
aria-label="KI Assistent"
|
||||||
|
title="KI Assistent"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Tenant switcher */}
|
{/* Tenant switcher */}
|
||||||
<div ref={tenantRef} className="relative">
|
<div ref={tenantRef} className="relative">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"auditLog": "Audit-Log",
|
"auditLog": "Audit-Log",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"aiAssistant": "KI Assistent"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"email": "Email",
|
"email": "Email",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"auditLog": "Audit Log",
|
"auditLog": "Audit Log",
|
||||||
"settings": "Settings"
|
"settings": "Settings",
|
||||||
|
"aiAssistant": "AI Assistant"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign In",
|
"login": "Sign In",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { SessionList } from '@/components/ai/SessionList';
|
||||||
|
import { ChatWindow } from '@/components/ai/ChatWindow';
|
||||||
|
|
||||||
|
export function AIAssistantPage() {
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full" data-testid="ai-assistant-page">
|
||||||
|
{/* Left: Session List */}
|
||||||
|
<div className="w-72 flex-shrink-0 border-r border-secondary-200 bg-white">
|
||||||
|
<div className="h-16 flex items-center px-6 border-b border-secondary-200">
|
||||||
|
<h1 className="text-lg font-bold text-secondary-900">KI Assistent</h1>
|
||||||
|
</div>
|
||||||
|
<SessionList
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
onSelectSession={setActiveSessionId}
|
||||||
|
className="h-[calc(100%-4rem)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Right: Chat Window */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{activeSessionId ? (
|
||||||
|
<ChatWindow sessionId={activeSessionId} className="flex-1" />
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-secondary-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-4xl mb-3">🤖</div>
|
||||||
|
<div className="text-sm">Wähle einen Chat oder erstelle einen neuen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Tabs } from '@/components/shared/Tabs';
|
||||||
|
import {
|
||||||
|
fetchProviders, createProvider, updateProvider, deleteProvider,
|
||||||
|
fetchPresets, createPreset, updatePreset, deletePreset,
|
||||||
|
fetchAgents, createAgent, updateAgent, deleteAgent,
|
||||||
|
fetchTools,
|
||||||
|
type AIProvider, type AIPreset, type AIAgent, type AITool,
|
||||||
|
} from '@/api/ai';
|
||||||
|
|
||||||
|
// ─── Provider Tab ───
|
||||||
|
function ProviderTab() {
|
||||||
|
const [providers, setProviders] = useState<AIProvider[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: '', provider_type: 'openai', api_key: '', base_url: '', is_default: false });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try { setProviders(await fetchProviders()); } catch (e: any) { setError(e?.message); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
if (editId) { await updateProvider(editId, form); } else { await createProvider(form); }
|
||||||
|
setShowForm(false); setEditId(null); setForm({ name: '', provider_type: 'openai', api_key: '', base_url: '', is_default: false });
|
||||||
|
await load();
|
||||||
|
} catch (e: any) { setError(e?.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (p: AIProvider) => {
|
||||||
|
setEditId(p.id); setForm({ name: p.name, provider_type: p.provider_type, api_key: '', base_url: p.base_url, is_default: p.is_default });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Anbieter wirklich löschen?')) return;
|
||||||
|
try { await deleteProvider(id); await load(); } catch (e: any) { setError(e?.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-4">Laden...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold">Anbieter</h2>
|
||||||
|
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm({ name: '', provider_type: 'openai', api_key: '', base_url: '', is_default: false }); }} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||||
|
{showForm && (
|
||||||
|
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<select value={form.provider_type} onChange={(e) => setForm({ ...form, provider_type: e.target.value })} className="border rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="anthropic">Anthropic</option>
|
||||||
|
<option value="ollama">Ollama</option>
|
||||||
|
<option value="azure">Azure OpenAI</option>
|
||||||
|
<option value="huggingface">HuggingFace</option>
|
||||||
|
<option value="custom">Custom</option>
|
||||||
|
</select>
|
||||||
|
<input placeholder="API Key" type="password" value={form.api_key} onChange={(e) => setForm({ ...form, api_key: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<input placeholder="Base URL (optional)" value={form.base_url} onChange={(e) => setForm({ ...form, base_url: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.is_default} onChange={(e) => setForm({ ...form, is_default: e.target.checked })} /> Standard-Anbieter
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">Speichern</button>
|
||||||
|
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{providers.map((p) => (
|
||||||
|
<div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{p.name} {p.is_default && <span className="text-xs text-primary-600">(Standard)</span>}</div>
|
||||||
|
<div className="text-xs text-secondary-500">{p.provider_type} · {p.api_key}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">Bearbeiten</button>
|
||||||
|
<button onClick={() => handleDelete(p.id)} className="text-sm text-red-600 hover:underline">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Presets Tab ───
|
||||||
|
function PresetTab() {
|
||||||
|
const [presets, setPresets] = useState<AIPreset[]>([]);
|
||||||
|
const [providers, setProviders] = useState<AIProvider[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: '', model_id: '', provider_id: '', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try { setPresets(await fetchPresets()); setProviders(await fetchProviders()); } catch (e: any) { setError(e?.message); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const data = { ...form, provider_id: form.provider_id || undefined };
|
||||||
|
if (editId) { await updatePreset(editId, data); } else { await createPreset(data); }
|
||||||
|
setShowForm(false); setEditId(null); setForm({ name: '', model_id: '', provider_id: '', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' });
|
||||||
|
await load();
|
||||||
|
} catch (e: any) { setError(e?.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (p: AIPreset) => {
|
||||||
|
setEditId(p.id); setForm({ name: p.name, model_id: p.model_id, provider_id: p.provider_id || '', temperature: p.temperature, max_tokens: p.max_tokens, top_p: p.top_p, system_prompt: p.system_prompt });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Preset löschen?')) return;
|
||||||
|
try { await deletePreset(id); await load(); } catch (e: any) { setError(e?.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-4">Laden...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold">Modelle & Presets</h2>
|
||||||
|
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm({ name: '', model_id: '', provider_id: '', temperature: 0.7, max_tokens: 2048, top_p: 1.0, system_prompt: '' }); }} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||||
|
{showForm && (
|
||||||
|
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input placeholder="Preset Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<input placeholder="Modell ID (z.B. gpt-4o-mini)" value={form.model_id} onChange={(e) => setForm({ ...form, model_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<select value={form.provider_id} onChange={(e) => setForm({ ...form, provider_id: e.target.value })} className="border rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="">Anbieter wählen...</option>
|
||||||
|
{providers.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<input type="number" step="0.1" min="0" max="2" placeholder="Temperature" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<input type="number" placeholder="Max Tokens" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<input type="number" step="0.1" min="0" max="1" placeholder="Top P" value={form.top_p} onChange={(e) => setForm({ ...form, top_p: parseFloat(e.target.value) })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<textarea placeholder="System Prompt (optional)" value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={3} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">Speichern</button>
|
||||||
|
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{presets.map((p) => (
|
||||||
|
<div key={p.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{p.name}</div>
|
||||||
|
<div className="text-xs text-secondary-500">{p.model_id} · temp={p.temperature} · max_tokens={p.max_tokens}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEdit(p)} className="text-sm text-primary-600 hover:underline">Bearbeiten</button>
|
||||||
|
<button onClick={() => handleDelete(p.id)} className="text-sm text-red-600 hover:underline">Löschen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Agents Tab ───
|
||||||
|
function AgentTab() {
|
||||||
|
const [agents, setAgents] = useState<AIAgent[]>([]);
|
||||||
|
const [presets, setPresets] = useState<AIPreset[]>([]);
|
||||||
|
const [tools, setTools] = useState<AITool[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState({ name: '', description: '', system_prompt: '', preset_id: '', tool_ids: [] as string[] });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try { setAgents(await fetchAgents()); setPresets(await fetchPresets()); setTools(await fetchTools()); } catch (e: any) { setError(e?.message); } finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const data = { ...form, preset_id: form.preset_id || undefined };
|
||||||
|
if (editId) { await updateAgent(editId, data); } else { await createAgent(data); }
|
||||||
|
setShowForm(false); setEditId(null); setForm({ name: '', description: '', system_prompt: '', preset_id: '', tool_ids: [] });
|
||||||
|
await load();
|
||||||
|
} catch (e: any) { setError(e?.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (a: AIAgent) => {
|
||||||
|
setEditId(a.id); setForm({ name: a.name, description: a.description, system_prompt: a.system_prompt, preset_id: a.preset_id || '', tool_ids: a.tool_ids || [] });
|
||||||
|
setShowForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('Agent löschen?')) return;
|
||||||
|
try { await deleteAgent(id); await load(); } catch (e: any) { setError(e?.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTool = (toolName: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tool_ids: prev.tool_ids.includes(toolName)
|
||||||
|
? prev.tool_ids.filter((t) => t !== toolName)
|
||||||
|
: [...prev.tool_ids, toolName],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="p-4">Laden...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold">Agenten</h2>
|
||||||
|
<button onClick={() => { setShowForm(!showForm); setEditId(null); setForm({ name: '', description: '', system_prompt: '', preset_id: '', tool_ids: [] }); }} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">+ Hinzufügen</button>
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||||
|
{showForm && (
|
||||||
|
<div className="border border-secondary-200 rounded-lg p-4 space-y-3 bg-secondary-50">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input placeholder="Name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
<input placeholder="Beschreibung" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} className="border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<select value={form.preset_id} onChange={(e) => setForm({ ...form, preset_id: e.target.value })} className="w-full border rounded-lg px-3 py-2 text-sm">
|
||||||
|
<option value="">Preset wählen...</option>
|
||||||
|
{presets.map((p) => <option key={p.id} value={p.id}>{p.name} ({p.model_id})</option>)}
|
||||||
|
</select>
|
||||||
|
<textarea placeholder="System Prompt" value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} rows={4} className="w-full border rounded-lg px-3 py-2 text-sm" />
|
||||||
|
{tools.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium mb-2">Tools:</div>
|
||||||
|
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<label key={tool.name} className="flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" checked={form.tool_ids.includes(tool.name)} onChange={() => toggleTool(tool.name)} />
|
||||||
|
<span>{tool.name} <span className="text-xs text-secondary-400">({tool.plugin_name})</span></span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleSave} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg">Speichern</button>
|
||||||
|
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 text-sm border rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{agents.map((a) => (
|
||||||
|
<div key={a.id} className="border border-secondary-200 rounded-lg p-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{a.name} {a.is_default && <span className="text-xs text-primary-600">(Standard)</span>}</div>
|
||||||
|
<div className="text-xs text-secondary-500">{a.description || 'Keine Beschreibung'}</div>
|
||||||
|
{a.tool_ids.length > 0 && <div className="text-xs text-secondary-400 mt-1">Tools: {a.tool_ids.join(', ')}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleEdit(a)} className="text-sm text-primary-600 hover:underline">Bearbeiten</button>
|
||||||
|
{!a.is_default && <button onClick={() => handleDelete(a.id)} className="text-sm text-red-600 hover:underline">Löschen</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tools Tab ───
|
||||||
|
function ToolsTab() {
|
||||||
|
const [tools, setTools] = useState<AITool[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTools().then(setTools).catch((e) => setError(e?.message)).finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div className="p-4">Laden...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Verfügbare Tools</h2>
|
||||||
|
<p className="text-sm text-secondary-500">Diese Tools werden von Plugins bereitgestellt und können Agenten zugewiesen werden.</p>
|
||||||
|
{error && <div className="text-sm text-red-600">{error}</div>}
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<div className="text-sm text-secondary-400 py-8 text-center">Keine Tools verfügbar. Plugins können Tools registrieren.</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<div key={tool.name} className="border border-secondary-200 rounded-lg p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-medium text-sm">{tool.name}</div>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
{tool.required_permission && <span className="px-2 py-0.5 rounded bg-amber-100 text-amber-700">{tool.required_permission}</span>}
|
||||||
|
<span className="px-2 py-0.5 rounded bg-secondary-100 text-secondary-600">{tool.plugin_name}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-secondary-100 text-secondary-600">{tool.category}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-secondary-500 mt-1">{tool.description}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Settings Page ───
|
||||||
|
export function AISettingsPage() {
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'providers', label: 'Anbieter', content: <ProviderTab /> },
|
||||||
|
{ key: 'presets', label: 'Modelle & Presets', content: <PresetTab /> },
|
||||||
|
{ key: 'agents', label: 'Agenten', content: <AgentTab /> },
|
||||||
|
{ key: 'tools', label: 'Tools', content: <ToolsTab /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<h1 className="text-2xl font-bold text-secondary-900 mb-6">KI Assistent Einstellungen</h1>
|
||||||
|
<Tabs tabs={tabs} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export function SettingsPage() {
|
|||||||
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
|
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
|
||||||
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
|
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
|
||||||
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
||||||
|
{ to: '/settings/ai', label: t('nav.aiAssistant'), icon: '\ud83e\udde0' },
|
||||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { DmsTrashPage } from '@/pages/DmsTrash';
|
|||||||
import { MailPage } from '@/pages/Mail';
|
import { MailPage } from '@/pages/Mail';
|
||||||
import { MailSettingsPage } from '@/pages/MailSettings';
|
import { MailSettingsPage } from '@/pages/MailSettings';
|
||||||
import { SettingsNotificationsPage } from '@/pages/SettingsNotifications';
|
import { SettingsNotificationsPage } from '@/pages/SettingsNotifications';
|
||||||
|
import { AIAssistantPage } from '@/pages/AIAssistant';
|
||||||
|
import { AISettingsPage } from '@/pages/AISettings';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -70,6 +72,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/dms/trash', element: <DmsTrashPage /> },
|
{ path: '/dms/trash', element: <DmsTrashPage /> },
|
||||||
{ path: '/mail', element: <MailPage /> },
|
{ path: '/mail', element: <MailPage /> },
|
||||||
{ path: '/mail/settings', element: <MailSettingsPage /> },
|
{ path: '/mail/settings', element: <MailSettingsPage /> },
|
||||||
|
{ path: '/ai-assistant', element: <AIAssistantPage /> },
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
element: <SettingsPage />,
|
element: <SettingsPage />,
|
||||||
@@ -85,6 +88,7 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'sequences', element: <SettingsSequencesPage /> },
|
{ path: 'sequences', element: <SettingsSequencesPage /> },
|
||||||
{ path: 'mail', element: <MailSettingsPage /> },
|
{ path: 'mail', element: <MailSettingsPage /> },
|
||||||
{ path: 'notifications', element: <SettingsNotificationsPage /> },
|
{ path: 'notifications', element: <SettingsNotificationsPage /> },
|
||||||
|
{ path: 'ai', element: <AISettingsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ export interface UIState {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
sidebarOpen: boolean;
|
sidebarOpen: boolean;
|
||||||
|
aiSidebarOpen: boolean;
|
||||||
toasts: Toast[];
|
toasts: Toast[];
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setLocale: (locale: Locale) => void;
|
setLocale: (locale: Locale) => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarOpen: (open: boolean) => void;
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
toggleAISidebar: () => void;
|
||||||
|
setAISidebarOpen: (open: boolean) => void;
|
||||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
clearToasts: () => void;
|
clearToasts: () => void;
|
||||||
@@ -30,6 +33,7 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
theme: (localStorage.getItem('leocrm_theme') as Theme) || 'light',
|
theme: (localStorage.getItem('leocrm_theme') as Theme) || 'light',
|
||||||
locale: (localStorage.getItem('leocrm_lang') as Locale) || 'de',
|
locale: (localStorage.getItem('leocrm_lang') as Locale) || 'de',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
|
aiSidebarOpen: false,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
localStorage.setItem('leocrm_theme', theme);
|
localStorage.setItem('leocrm_theme', theme);
|
||||||
@@ -41,6 +45,8 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
},
|
},
|
||||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||||
|
toggleAISidebar: () => set((s) => ({ aiSidebarOpen: !s.aiSidebarOpen })),
|
||||||
|
setAISidebarOpen: (open) => set({ aiSidebarOpen: open }),
|
||||||
addToast: (toast) => {
|
addToast: (toast) => {
|
||||||
const id = `toast-${++toastIdCounter}`;
|
const id = `toast-${++toastIdCounter}`;
|
||||||
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
|
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
|
||||||
|
|||||||
Reference in New Issue
Block a user