AI Assistant: frontend - chat app, sidebar, settings with 4 tabs

This commit is contained in:
Agent Zero
2026-07-17 00:52:17 +02:00
parent 26feadf179
commit 2d5d0143e6
14 changed files with 930 additions and 15 deletions
+36
View File
@@ -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>
);
}
+332
View File
@@ -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>
);
}
+1
View File
@@ -16,6 +16,7 @@ export function SettingsPage() {
{ to: '/settings/taxes', label: t('taxes.title'), icon: '\ud83d\udccb' },
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
{ 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' },
];