Files
leocrm/frontend/src/components/ai/SessionList.tsx
T

243 lines
8.4 KiB
TypeScript
Raw Normal View History

import React, { useEffect, useState } from 'react';
import clsx from 'clsx';
import {
fetchSessions, createSession, deleteSession, updateSession,
fetchFolders, createFolder, deleteFolder, updateFolder,
type ChatSession, type ChatFolder,
} from '@/api/ai';
interface SessionListProps {
activeSessionId: string | null;
onSelectSession: (id: string) => void;
isSidebar?: boolean;
className?: string;
}
interface TreeNode {
folder: ChatFolder | null;
sessions: ChatSession[];
children: TreeNode[];
}
function buildTree(folders: ChatFolder[], sessions: ChatSession[]): TreeNode[] {
const rootSessions = sessions.filter((s) => !s.folder_id);
const root: TreeNode = { folder: null, sessions: rootSessions, children: [] };
const folderMap = new Map<string, TreeNode>();
for (const f of folders) {
folderMap.set(f.id, { folder: f, sessions: [], children: [] });
}
for (const f of folders) {
const node = folderMap.get(f.id)!;
if (f.parent_id && folderMap.has(f.parent_id)) {
folderMap.get(f.parent_id)!.children.push(node);
} else {
root.children.push(node);
}
}
for (const s of sessions) {
if (s.folder_id && folderMap.has(s.folder_id)) {
folderMap.get(s.folder_id)!.sessions.push(s);
}
}
return [root];
}
function TreeItem({
node, activeSessionId, onSelectSession, depth, onMoveSession, onDeleteFolder, onAddFolder,
}: {
node: TreeNode;
activeSessionId: string | null;
onSelectSession: (id: string) => void;
depth: number;
onMoveSession: (sessionId: string, folderId: string | null) => void;
onDeleteFolder: (folderId: string) => void;
onAddFolder: (parentId: string | null) => void;
}) {
const [expanded, setExpanded] = useState(true);
const isRoot = node.folder === null;
return (
<div>
{/* Folder header */}
{!isRoot && (
<div
className="group flex items-center gap-1 px-2 py-1.5 text-sm font-medium text-secondary-700 hover:bg-secondary-100 rounded-md cursor-pointer"
style={{ paddingLeft: depth * 12 + 8 }}
onClick={() => setExpanded(!expanded)}
>
<svg className={clsx('w-4 h-4 transition-transform', expanded && 'rotate-90')} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<svg className="w-4 h-4 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span className="flex-1 truncate">{node.folder!.name}</span>
<button
onClick={(e) => { e.stopPropagation(); onAddFolder(node.folder!.id); }}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600"
title="Unterordner"
>+</button>
<button
onClick={(e) => { e.stopPropagation(); onDeleteFolder(node.folder!.id); }}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600"
title="Löschen"
></button>
</div>
)}
{/* Sessions in this folder */}
{(isRoot || expanded) && (
<div>
{node.sessions.map((session) => (
<div
key={session.id}
onClick={() => onSelectSession(session.id)}
className={clsx(
'group flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer rounded-md',
'hover:bg-secondary-100',
activeSessionId === session.id && 'bg-primary-100 text-primary-700'
)}
style={{ paddingLeft: depth * 12 + 24 }}
>
<svg className="w-3.5 h-3.5 text-secondary-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h8M8 8h8m-8 4h8m-8 4h8" />
</svg>
<span className="flex-1 truncate">{session.title}</span>
<button
onClick={(e) => {
e.stopPropagation();
const folderId = node.folder?.id || null;
onMoveSession(session.id, folderId ? null : 'root');
}}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-primary-600"
title="Verschieben"
></button>
<button
onClick={(e) => { e.stopPropagation(); deleteSession(session.id).then(() => window.location.reload()); }}
className="opacity-0 group-hover:opacity-100 text-secondary-400 hover:text-red-600"
title="Löschen"
></button>
</div>
))}
</div>
)}
{/* Child folders */}
{(isRoot || expanded) && node.children.map((child) => (
<TreeItem
key={child.folder!.id}
node={child}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
depth={depth + 1}
onMoveSession={onMoveSession}
onDeleteFolder={onDeleteFolder}
onAddFolder={onAddFolder}
/>
))}
</div>
);
}
export function SessionList({ activeSessionId, onSelectSession, isSidebar, className }: SessionListProps) {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [folders, setFolders] = useState<ChatFolder[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
try {
setLoading(true);
const [s, f] = await Promise.all([
fetchSessions(isSidebar ? true : undefined),
fetchFolders(),
]);
setSessions(s);
setFolders(f);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load');
} finally {
setLoading(false);
}
};
useEffect(() => { loadData(); }, [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 handleAddFolder = (parentId: string | null) => {
const name = prompt('Ordnername:');
if (!name) return;
createFolder({ name, parent_id: parentId || undefined })
.then(() => loadData())
.catch((e) => setError(e?.message));
};
const handleDeleteFolder = (folderId: string) => {
if (!confirm('Ordner löschen? Chats bleiben erhalten.')) return;
deleteFolder(folderId).then(() => loadData()).catch((e) => setError(e?.message));
};
const handleMoveSession = async (sessionId: string, target: string) => {
const folderId = target === 'root' ? null : target;
try {
await updateSession(sessionId, { folder_id: folderId });
loadData();
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to move');
}
};
if (loading) return <div className="p-4 text-sm text-secondary-400">Laden...</div>;
const tree = buildTree(folders, sessions);
return (
<div className={clsx('flex flex-col h-full', className)}>
<div className="p-3 border-b border-secondary-200 flex gap-2">
<button
onClick={handleNewChat}
className="flex-1 rounded-lg bg-primary-600 text-white px-3 py-2 text-sm font-medium hover:bg-primary-700"
>
+ Neuer Chat
</button>
<button
onClick={() => handleAddFolder(null)}
className="rounded-lg border border-secondary-300 px-3 py-2 text-sm hover:bg-secondary-100"
title="Neuer Ordner"
>
📁+
</button>
</div>
{error && <div className="p-2 text-xs text-red-600">{error}</div>}
<div className="flex-1 overflow-y-auto p-2">
{tree.map((node) => (
<TreeItem
key={node.folder?.id || 'root'}
node={node}
activeSessionId={activeSessionId}
onSelectSession={onSelectSession}
depth={0}
onMoveSession={handleMoveSession}
onDeleteFolder={handleDeleteFolder}
onAddFolder={handleAddFolder}
/>
))}
</div>
</div>
);
}