frontend_nyla/src/components/UnifiedDataBar/ChatsTab.tsx
2026-04-11 19:44:52 +02:00

446 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import styles from './ChatsTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface ChatItem {
id: string;
label: string;
updatedAt?: string | number;
lastMessageAt?: string | number;
featureInstanceId?: string;
featureCode?: string;
status?: string;
}
interface ChatGroup {
featureInstanceId: string;
featureLabel: string;
featureCode: string;
chats: ChatItem[];
}
type ChatFilter = 'active' | 'archived';
interface ChatsTabProps {
context: UdbContext;
onSelectChat?: (chatId: string, featureInstanceId: string) => void;
onDragStart?: (chatId: string, event: React.DragEvent) => void;
activeWorkflowId?: string;
onCreateNew?: () => void;
onRenameChat?: (chatId: string, newName: string) => void | Promise<void>;
onDeleteChat?: (chatId: string) => void | Promise<void>;
}
function _formatRelativeTime(dateStr?: string | number): string {
if (!dateStr) return '';
const d = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr);
if (isNaN(d.getTime())) return '';
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffH = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return 'gerade eben';
if (diffMin < 60) return `${diffMin}m`;
if (diffH < 24) return `${diffH}h`;
if (diffDays === 1) return 'gestern';
if (diffDays < 7) return `vor ${diffDays}d`;
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
const ChatsTab: React.FC<ChatsTabProps> = ({ context,
onSelectChat,
onDragStart,
activeWorkflowId,
onCreateNew,
onRenameChat,
onDeleteChat,
}) => {
const { t } = useLanguage();
const [groups, setGroups] = useState<ChatGroup[]>([]);
const [flatMode, setFlatMode] = useState(false);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ChatFilter>('active');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const _loadChats = useCallback(async (serverSearch?: string) => {
setLoading(true);
try {
const params: Record<string, unknown> = { includeArchived: true };
if (serverSearch) params.search = serverSearch;
const response = await api.get(
`/api/workspace/${context.instanceId}/workflows`,
{ params },
);
const body = response.data ?? {};
const nested = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
? (body.data as { workflows?: unknown })
: null;
const workflowsRaw =
body.workflows ??
nested?.workflows ??
(Array.isArray(body.data) ? body.data : null);
const workflows = Array.isArray(workflowsRaw) ? workflowsRaw : [];
const groupMap = new Map<string, ChatGroup>();
for (const wf of workflows) {
const fiId = wf.featureInstanceId || context.instanceId;
if (!groupMap.has(fiId)) {
groupMap.set(fiId, {
featureInstanceId: fiId,
featureLabel: wf.featureLabel || wf.featureCode || fiId.slice(0, 8),
featureCode: wf.featureCode || 'workspace',
chats: [],
});
}
groupMap.get(fiId)!.chats.push({
id: wf.id,
label: wf.label || wf.name || `${t('Chat')} ${wf.id.slice(0, 8)}`,
updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt,
lastMessageAt: wf.lastMessageAt,
featureInstanceId: fiId,
featureCode: wf.featureCode,
status: wf.status || 'active',
});
}
const sorted = Array.from(groupMap.values());
sorted.forEach(g =>
g.chats.sort((a, b) => {
const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime();
const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime();
return tb - ta;
}),
);
setGroups(sorted);
if (expandedGroups.size === 0 && sorted.length > 0) {
const currentGroup = sorted.find(g => g.featureInstanceId === context.instanceId);
const sectionKey = currentGroup ? `section:${currentGroup.featureCode || 'workspace'}` : 'section:workspace';
setExpandedGroups(new Set([context.instanceId, sectionKey]));
}
} catch (err) {
console.error('Failed to load chats:', err);
} finally {
setLoading(false);
}
}, [context.instanceId, t]);
useEffect(() => { _loadChats(); }, [_loadChats]);
useEffect(() => {
const timer = setTimeout(() => {
if (search.trim().length >= 2) {
_loadChats(search.trim());
} else if (search.trim().length === 0) {
_loadChats();
}
}, 300);
return () => clearTimeout(timer);
}, [search, _loadChats]);
useEffect(() => {
if (activeWorkflowId) {
_loadChats();
}
}, [activeWorkflowId]);
useEffect(() => {
if (editingId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [editingId]);
const _toggleGroup = (id: string) => {
setExpandedGroups(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const _startEditing = (chat: ChatItem) => {
if (!onRenameChat) return;
setEditingId(chat.id);
setEditName(chat.label);
};
const _commitRename = async (chatId: string) => {
const trimmed = editName.trim();
setEditingId(null);
if (!trimmed || !onRenameChat) return;
await onRenameChat(chatId, trimmed);
_loadChats();
};
const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => {
if (e.key === 'Enter') {
e.preventDefault();
_commitRename(chatId);
} else if (e.key === 'Escape') {
setEditingId(null);
}
};
const _archiveChat = useCallback(async (chatId: string) => {
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' });
_loadChats();
} catch (err) {
console.error('Failed to archive chat:', err);
}
}, [context.instanceId, _loadChats]);
const _restoreChat = useCallback(async (chatId: string) => {
try {
await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' });
_loadChats();
} catch (err) {
console.error('Failed to restore chat:', err);
}
}, [context.instanceId, _loadChats]);
const _isArchived = (chat: ChatItem) => chat.status === 'archived';
const _applyFilter = (chats: ChatItem[]) =>
chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c));
const _filteredGroups = groups
.map(g => ({ ...g, chats: _applyFilter(g.chats) }))
.filter(g => g.chats.length > 0);
const _toTs = (v?: string | number): number =>
typeof v === 'number' ? v : new Date(v || 0).getTime();
const _allChats = _filteredGroups
.flatMap(g => g.chats)
.sort((a, b) => {
const ta = _toTs(a.lastMessageAt ?? a.updatedAt);
const tb = _toTs(b.lastMessageAt ?? b.updatedAt);
return tb - ta;
});
const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0);
const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0);
const _renderChatItem = (chat: ChatItem, featureInstanceId: string) => {
const isActive = activeWorkflowId === chat.id;
const isEditing = editingId === chat.id;
const archived = _isArchived(chat);
const itemClassName = [
styles.chatItem,
isActive ? styles.chatItemActive : '',
archived ? styles.chatItemArchived : '',
].filter(Boolean).join(' ');
return (
<div
key={chat.id}
className={itemClassName}
onClick={() => {
if (!isEditing) onSelectChat?.(chat.id, featureInstanceId);
}}
draggable={!!onDragStart && !isEditing}
onDragStart={(e) => {
e.dataTransfer.setData('application/chat-id', chat.id);
e.dataTransfer.setData('text/plain', chat.label);
onDragStart?.(chat.id, e);
}}
>
{isEditing ? (
<input
ref={renameInputRef}
className={styles.renameInput}
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={() => _commitRename(chat.id)}
onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<span className={styles.chatDate}>
{_formatRelativeTime(chat.updatedAt)}
</span>
<span
className={styles.chatLabel}
title={chat.label}
>
{chat.label}
</span>
<span className={styles.chatActions}>
{onRenameChat && (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _startEditing(chat); }}
title={t('Umbenennen')}
>
</button>
)}
{archived ? (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _restoreChat(chat.id); }}
title={t('Wiederherstellen')}
>
</button>
) : (
<button
className={styles.actionBtn}
onClick={(e) => { e.stopPropagation(); _archiveChat(chat.id); }}
title={t('Archivieren')}
>
📦
</button>
)}
{onDeleteChat && (
<button
className={`${styles.actionBtn} ${styles.actionBtnDanger}`}
onClick={async (e) => { e.stopPropagation(); await onDeleteChat(chat.id); _loadChats(); }}
title={t('Löschen')}
>
🗑
</button>
)}
</span>
</>
)}
</div>
);
};
const _featureCodeLabel = (code: string): string => {
const labels: Record<string, string> = {
workspace: t('KI-Arbeitsbereich'),
commcoach: t('CommCoach'),
trustee: t('Trustee'),
automation: t('Automation'),
};
return labels[code] || code;
};
if (loading) return <div className={styles.loading}>{t('Chats werden geladen…')}</div>;
return (
<div className={styles.chatsTab}>
<div className={styles.toolbar}>
<input
className={styles.search}
type="text"
placeholder={t('Suchen')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{onCreateNew && (
<button className={styles.createBtn} onClick={() => { onCreateNew(); setTimeout(_loadChats, 500); }} title={t('Neuer Chat')}>
+
</button>
)}
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
title={flatMode ? t('Baumansicht') : t('Listenansicht')}
>
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
</button>
</div>
<div className={styles.filterTabs}>
<button
className={`${styles.filterTab} ${filter === 'active' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('active')}
>
{t('Aktiv')} ({_activeCount})
</button>
<button
className={`${styles.filterTab} ${filter === 'archived' ? styles.filterTabActive : ''}`}
onClick={() => setFilter('archived')}
>
{t('Archiv')} ({_archivedCount})
</button>
</div>
{flatMode ? (
<div className={styles.flatList}>
{_allChats.map((chat) =>
_renderChatItem(chat, chat.featureInstanceId || context.instanceId),
)}
</div>
) : (
<div className={styles.tree}>
{(() => {
const byFeatureCode = new Map<string, ChatGroup[]>();
for (const g of _filteredGroups) {
const code = g.featureCode || 'workspace';
if (!byFeatureCode.has(code)) byFeatureCode.set(code, []);
byFeatureCode.get(code)!.push(g);
}
return Array.from(byFeatureCode.entries()).map(([code, instances]) => (
<div key={code} className={styles.treeSection}>
<div
className={styles.treeSectionHeader}
onClick={() => _toggleGroup(`section:${code}`)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeSectionLabel}>
{_featureCodeLabel(code)}
</span>
<span className={styles.treeGroupCount}>
{instances.reduce((n, g) => n + g.chats.length, 0)}
</span>
</div>
{expandedGroups.has(`section:${code}`) && instances.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
{instances.length > 1 && (
<div
className={`${styles.treeGroupHeader} ${
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
}`}
onClick={() => _toggleGroup(group.featureInstanceId)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
<span className={styles.treeGroupCount}>{group.chats.length}</span>
</div>
)}
{(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) =>
_renderChatItem(chat, group.featureInstanceId),
)}
</div>
)}
</div>
))}
</div>
));
})()}
</div>
)}
{_allChats.length === 0 && (
<div className={styles.emptyState}>
{filter === 'archived' ? t('Keine archivierten Chats') : t('Keine aktiven Chats')}
</div>
)}
</div>
);
};
export default ChatsTab;