fix(i-d): ai/sessions-API-Bruche behoben — tote Frontend-Calls eliminiert statt Backend-Shims
Root-Cause: Backend hat KEIN /ai/sessions-CRUD (nur Conversations-Routen im kommunikation/ai_assistant). Frontend-Nutzer war NUR AISidebar — dessen Chat-Tab renderte nie einen echten Chat sondern nur Platzhalter gesteuert von Session-Calls auf 404. Nach AGENTS.md 0.2/0.3 keine Backend-Shims gebaut: (1) Geister-Tests ChatWindow.test.tsx + SessionList.test.tsx geloescht — importierten nicht existierende Komponenten @/components/ai/ChatWindow + SessionList (BUG-099-Muster, Plan sanktioniert Loeschung). (2) AISidebar: tote fetchSessions/createSession-Calls + sessionId/loading-State entfernt; Chat-Tab zeigt jetzt Verweis-Link auf existierende /ai-assistant-Seite (962e0ee). (3) api/ai.ts 253→170 Zeilen: tote Interfaces ChatFolder/ChatSession/ChatMessage/ChatAttachment + Folders/Sessions/Attachments-Sektionen entfernt; fetchMessages/streamChat bleiben (genutzt von AiChatPanel/Communication).
Beweise: tsc --noEmit exit=0; vitest src/__tests__/ai/ 26/26 gruen (vorher 2 Geister-Suites mit Import-Error); ruff unberuehrt.
This commit is contained in:
@@ -1,113 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
||||||
import { ChatWindow } from '@/components/ai/ChatWindow';
|
|
||||||
|
|
||||||
const mockMessages = [
|
|
||||||
{ id: 'm1', session_id: 's1', role: 'user', content: 'Hello AI', tokens: 10, model_used: '' },
|
|
||||||
{ id: 'm2', session_id: 's1', role: 'assistant', content: 'Hello User', tokens: 10, model_used: 'gpt-4' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const fetchMessagesMock = vi.fn();
|
|
||||||
const fetchAttachmentsMock = vi.fn();
|
|
||||||
const streamChatMock = vi.fn();
|
|
||||||
const uploadAttachmentMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('@/api/ai', () => ({
|
|
||||||
fetchMessages: (...args: any[]) => fetchMessagesMock(...args),
|
|
||||||
fetchAttachments: (...args: any[]) => fetchAttachmentsMock(...args),
|
|
||||||
streamChat: (...args: any[]) => streamChatMock(...args),
|
|
||||||
uploadAttachment: (...args: any[]) => uploadAttachmentMock(...args),
|
|
||||||
getAttachmentDownloadUrl: (id: string) => `/api/v1/ai/attachments/${id}/download`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('react-markdown', () => ({
|
|
||||||
default: ({ children }: { children: string }) => <div>{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('remark-gfm', () => ({
|
|
||||||
default: () => ({}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
fetchMessagesMock.mockResolvedValue(mockMessages);
|
|
||||||
fetchAttachmentsMock.mockResolvedValue([]);
|
|
||||||
streamChatMock.mockReturnValue({
|
|
||||||
async *[Symbol.asyncIterator]() {
|
|
||||||
yield { type: 'token', content: 'Hi' };
|
|
||||||
yield { type: 'done' };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ChatWindow', () => {
|
|
||||||
it('renders chat window with messages', async () => {
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Hello AI')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Hello User')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders textarea input for message', async () => {
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders send button', async () => {
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Senden')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables send button when input is empty', async () => {
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
const sendBtn = screen.getByText('Senden');
|
|
||||||
expect(sendBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enables send button when input has text', async () => {
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
const input = screen.getByPlaceholderText('Nachricht eingeben...');
|
|
||||||
fireEvent.change(input, { target: { value: 'Test message' } });
|
|
||||||
expect(screen.getByText('Senden')).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows empty state when no messages', async () => {
|
|
||||||
fetchMessagesMock.mockResolvedValueOnce([]);
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Starte eine Konversation...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error when fetchMessages fails', async () => {
|
|
||||||
fetchMessagesMock.mockRejectedValueOnce(new Error('Load failed'));
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Load failed')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends message on button click', async () => {
|
|
||||||
render(<ChatWindow sessionId="s1" />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText('Nachricht eingeben...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
const input = screen.getByPlaceholderText('Nachricht eingeben...');
|
|
||||||
fireEvent.change(input, { target: { value: 'Test message' } });
|
|
||||||
fireEvent.click(screen.getByText('Senden'));
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(streamChatMock).toHaveBeenCalledWith('s1', 'Test message', undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { SessionList } from '@/components/ai/SessionList';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderWithProvider(ui: React.ReactElement) {
|
|
||||||
return render(
|
|
||||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mockSessions = [
|
|
||||||
{ id: 's1', title: 'First Chat', folder_id: null, agent_id: null, is_sidebar: false, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
|
||||||
{ id: 's2', title: 'Second Chat', folder_id: 'f1', agent_id: null, is_sidebar: false, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockFolders = [
|
|
||||||
{ id: 'f1', name: 'My Folder', parent_id: null, created_at: '2024-01-01T00:00:00Z' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const fetchSessionsMock = vi.fn();
|
|
||||||
const fetchFoldersMock = vi.fn();
|
|
||||||
const createSessionMock = vi.fn();
|
|
||||||
const deleteSessionMock = vi.fn();
|
|
||||||
const updateSessionMock = vi.fn();
|
|
||||||
const createFolderMock = vi.fn();
|
|
||||||
const deleteFolderMock = vi.fn();
|
|
||||||
const updateFolderMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('@/api/ai', () => ({
|
|
||||||
fetchSessions: (...args: any[]) => fetchSessionsMock(...args),
|
|
||||||
fetchFolders: (...args: any[]) => fetchFoldersMock(...args),
|
|
||||||
createSession: (...args: any[]) => createSessionMock(...args),
|
|
||||||
deleteSession: (...args: any[]) => deleteSessionMock(...args),
|
|
||||||
updateSession: (...args: any[]) => updateSessionMock(...args),
|
|
||||||
createFolder: (...args: any[]) => createFolderMock(...args),
|
|
||||||
deleteFolder: (...args: any[]) => deleteFolderMock(...args),
|
|
||||||
updateFolder: (...args: any[]) => updateFolderMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
fetchSessionsMock.mockResolvedValue(mockSessions);
|
|
||||||
fetchFoldersMock.mockResolvedValue(mockFolders);
|
|
||||||
createSessionMock.mockResolvedValue({ id: 's3', title: 'New Chat', folder_id: null, agent_id: null, is_sidebar: false, created_at: '', updated_at: '' });
|
|
||||||
deleteSessionMock.mockResolvedValue({});
|
|
||||||
updateSessionMock.mockResolvedValue({});
|
|
||||||
createFolderMock.mockResolvedValue({ id: 'f2', name: 'New Folder', parent_id: null, created_at: '' });
|
|
||||||
deleteFolderMock.mockResolvedValue({});
|
|
||||||
updateFolderMock.mockResolvedValue({});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SessionList', () => {
|
|
||||||
it('renders session list with sessions', async () => {
|
|
||||||
renderWithProvider(<SessionList activeSessionId="s1" onSelectSession={vi.fn()} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('First Chat')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders folder names', async () => {
|
|
||||||
renderWithProvider(<SessionList activeSessionId="s1" onSelectSession={vi.fn()} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('My Folder')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders sessions inside folders', async () => {
|
|
||||||
renderWithProvider(<SessionList activeSessionId="s1" onSelectSession={vi.fn()} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Second Chat')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onSelectSession when session clicked', async () => {
|
|
||||||
const onSelect = vi.fn();
|
|
||||||
renderWithProvider(<SessionList activeSessionId={null} onSelectSession={onSelect} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('First Chat')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText('First Chat'));
|
|
||||||
expect(onSelect).toHaveBeenCalledWith('s1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows loading state initially', () => {
|
|
||||||
fetchSessionsMock.mockReturnValue(new Promise(() => {}));
|
|
||||||
fetchFoldersMock.mockReturnValue(new Promise(() => {}));
|
|
||||||
renderWithProvider(<SessionList activeSessionId={null} onSelectSession={vi.fn()} />);
|
|
||||||
expect(screen.getByText('Laden...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays error when fetch fails', async () => {
|
|
||||||
fetchSessionsMock.mockRejectedValueOnce(new Error('Failed to load'));
|
|
||||||
renderWithProvider(<SessionList activeSessionId={null} onSelectSession={vi.fn()} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Failed to load')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -61,47 +61,6 @@ export interface AIAgent {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatFolder {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
user_id: string;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatSession {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
agent_id: string | null;
|
|
||||||
title: string;
|
|
||||||
is_pinned: boolean;
|
|
||||||
is_sidebar: boolean;
|
|
||||||
folder_id: string | null;
|
|
||||||
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 ChatAttachment {
|
|
||||||
id: string;
|
|
||||||
message_id: string | null;
|
|
||||||
session_id: string;
|
|
||||||
filename: string;
|
|
||||||
mime_type: string;
|
|
||||||
size_bytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AITool {
|
export interface AITool {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -147,53 +106,11 @@ export const fetchTools = async () => {
|
|||||||
return Array.isArray(res) ? res : res.items ?? [];
|
return Array.isArray(res) ? res : res.items ?? [];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Folders ───
|
|
||||||
|
|
||||||
export const fetchFolders = () => apiGet<ChatFolder[]>('/ai/folders');
|
|
||||||
export const createFolder = (data: { name: string; parent_id?: string }) => apiPost<ChatFolder>('/ai/folders', data);
|
|
||||||
export const updateFolder = (id: string, data: Partial<ChatFolder>) => apiPut<ChatFolder>(`/ai/folders/${id}`, data);
|
|
||||||
export const deleteFolder = (id: string) => apiDelete(`/ai/folders/${id}`);
|
|
||||||
|
|
||||||
// ─── 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; folder_id?: string }) =>
|
|
||||||
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 ───
|
// ─── Messages ───
|
||||||
|
|
||||||
export const fetchMessages = (conversationId: string) =>
|
export const fetchMessages = (conversationId: string) =>
|
||||||
apiGet<{ role: string; content: string }[]>(`/ai/conversations/${conversationId}/messages`);
|
apiGet<{ role: string; content: string }[]>(`/ai/conversations/${conversationId}/messages`);
|
||||||
|
|
||||||
// ─── Attachments ───
|
|
||||||
|
|
||||||
export const fetchAttachments = (sessionId: string) =>
|
|
||||||
apiGet<ChatAttachment[]>(`/ai/sessions/${sessionId}/attachments`);
|
|
||||||
|
|
||||||
export async function uploadAttachment(sessionId: string, file: File): Promise<ChatAttachment> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const csrfToken = sessionStorage.getItem('leocrm_csrf_token');
|
|
||||||
const response = await fetch(`/api/v1/ai/sessions/${sessionId}/attachments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAttachmentDownloadUrl(attachmentId: string): string {
|
|
||||||
return `/api/v1/ai/attachments/${attachmentId}/download`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Streaming Chat ───
|
// ─── Streaming Chat ───
|
||||||
|
|
||||||
export interface StreamEvent {
|
export interface StreamEvent {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||||
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
import { SuggestionList } from '@/components/ai/SuggestionSidebar';
|
||||||
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
import { ImprovementPanel } from '@/components/ai/ImprovementPanel';
|
||||||
import { createSession, fetchSessions } from '@/api/ai';
|
|
||||||
import { useUIStore } from '@/store/uiStore';
|
import { useUIStore } from '@/store/uiStore';
|
||||||
import { getContributedTabs } from './sidebarTabs';
|
import { getContributedTabs } from './sidebarTabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -56,26 +56,6 @@ function ChatPanel() {
|
|||||||
export function AISidebar() {
|
export function AISidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { aiSidebarCollapsed, toggleAISidebar, aiSidebarTab, setAISidebarTab, notifications, removeNotification } = useUIStore();
|
const { aiSidebarCollapsed, toggleAISidebar, aiSidebarTab, setAISidebarTab, notifications, removeNotification } = useUIStore();
|
||||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const tabs: TabDef[] = [
|
const tabs: TabDef[] = [
|
||||||
{ key: 'chat', label: 'KI Chat', icon: robotIcon, testId: 'ai-sidebar-tab-chat' },
|
{ key: 'chat', label: 'KI Chat', icon: robotIcon, testId: 'ai-sidebar-tab-chat' },
|
||||||
@@ -166,18 +146,18 @@ export function AISidebar() {
|
|||||||
const ContributedComponent = contributed.component;
|
const ContributedComponent = contributed.component;
|
||||||
return <ContributedComponent />;
|
return <ContributedComponent />;
|
||||||
}
|
}
|
||||||
// chat tab
|
// chat tab — voller KI-Chat lebt auf der /ai-assistant-Seite
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-sm text-secondary-400">Laden...</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (sessionId) {
|
|
||||||
return <div className="flex items-center justify-center h-full text-secondary-400 text-sm">KI Chat im Kommunikations-Plugin verfügbar</div>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-red-500">
|
<div className="flex flex-col items-center justify-center h-full text-center px-4 gap-3">
|
||||||
Session konnte nicht erstellt werden
|
<p className="text-sm text-secondary-500">
|
||||||
|
Der volle KI-Chat ist auf der AI-Assistant-Seite verfügbar.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/ai-assistant"
|
||||||
|
className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch"
|
||||||
|
>
|
||||||
|
AI Assistant öffnen
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user