diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 0000000..dc69aed --- /dev/null +++ b/frontend/src/api/ai.ts @@ -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; + 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; +} + +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; + 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; + 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[]; + tool_results?: Record[]; + tokens: number; + model_used: string; + created_at?: string; +} + +export interface AITool { + name: string; + description: string; + parameters: Record; + plugin_name: string; + required_permission: string | null; + category: string; +} + +// ─── Providers ─── + +export const fetchProviders = () => apiGet('/ai/providers'); +export const createProvider = (data: Partial) => apiPost('/ai/providers', data); +export const updateProvider = (id: string, data: Partial) => apiPut(`/ai/providers/${id}`, data); +export const deleteProvider = (id: string) => apiDelete(`/ai/providers/${id}`); + +// ─── Models ─── + +export const fetchModels = (providerId?: string) => + apiGet('/ai/models', { params: providerId ? { provider_id: providerId } : {} }); +export const createModel = (data: Partial) => apiPost('/ai/models', data); +export const updateModel = (id: string, data: Partial) => apiPut(`/ai/models/${id}`, data); +export const deleteModel = (id: string) => apiDelete(`/ai/models/${id}`); + +// ─── Presets ─── + +export const fetchPresets = () => apiGet('/ai/presets'); +export const createPreset = (data: Partial) => apiPost('/ai/presets', data); +export const updatePreset = (id: string, data: Partial) => apiPut(`/ai/presets/${id}`, data); +export const deletePreset = (id: string) => apiDelete(`/ai/presets/${id}`); + +// ─── Agents ─── + +export const fetchAgents = () => apiGet('/ai/agents'); +export const createAgent = (data: Partial) => apiPost('/ai/agents', data); +export const updateAgent = (id: string, data: Partial) => apiPut(`/ai/agents/${id}`, data); +export const deleteAgent = (id: string) => apiDelete(`/ai/agents/${id}`); + +// ─── Tools ─── + +export const fetchTools = () => apiGet('/ai/tools'); + +// ─── Sessions ─── + +export const fetchSessions = (isSidebar?: boolean) => + apiGet('/ai/sessions', { params: isSidebar !== undefined ? { is_sidebar: isSidebar } : {} }); +export const createSession = (data: { title?: string; agent_id?: string; is_sidebar?: boolean }) => + apiPost('/ai/sessions', data); +export const updateSession = (id: string, data: Partial) => + apiPut(`/ai/sessions/${id}`, data); +export const deleteSession = (id: string) => apiDelete(`/ai/sessions/${id}`); + +// ─── Messages ─── + +export const fetchMessages = (sessionId: string) => + apiGet(`/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 { + 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 + } + } + } + } +} diff --git a/frontend/src/components/ai/ChatWindow.tsx b/frontend/src/components/ai/ChatWindow.tsx new file mode 100644 index 0000000..d2ac8b0 --- /dev/null +++ b/frontend/src/components/ai/ChatWindow.tsx @@ -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([]); + const [input, setInput] = useState(''); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [toolStatus, setToolStatus] = useState(null); + const [error, setError] = useState(null); + const scrollRef = useRef(null); + const inputRef = useRef(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 ( +
+
+ {messages.length === 0 && !streamingContent && !error && ( +
+ Starte eine Konversation... +
+ )} + {messages.map((msg) => ( +
+
+
{msg.content}
+ {msg.tool_calls && msg.tool_calls.length > 0 && ( +
+ Tools: {msg.tool_calls.map((tc: any) => tc.function?.name).join(', ')} +
+ )} +
+
+ ))} + {streamingContent && ( +
+
+
{streamingContent}
+
+
+ )} + {toolStatus && ( +
⚙️ {toolStatus}
+ )} + {error && ( +
+
{error}
+
+ )} +
+
+
+