mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import type { ActionableItem, SnoozeOption } from '../../types/intelligence';
|
||||
|
||||
interface ActionableCardProps {
|
||||
item: ActionableItem;
|
||||
onComplete: (item: ActionableItem) => void;
|
||||
onDismiss: (item: ActionableItem) => void;
|
||||
onSnooze: (item: ActionableItem, duration: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SNOOZE_OPTIONS: SnoozeOption[] = [
|
||||
{ label: '1 hour', duration: 60 * 60 * 1000 },
|
||||
{ label: '6 hours', duration: 6 * 60 * 60 * 1000 },
|
||||
{ label: '24 hours', duration: 24 * 60 * 60 * 1000 },
|
||||
];
|
||||
|
||||
// Portal component for snooze dropdown to escape stacking contexts
|
||||
interface SnoozeDropdownPortalProps {
|
||||
isOpen: boolean;
|
||||
buttonRef: React.RefObject<HTMLButtonElement | null>;
|
||||
onClose: () => void;
|
||||
onSnooze: (duration: number) => void;
|
||||
}
|
||||
|
||||
function SnoozeDropdownPortal({ isOpen, buttonRef, onClose, onSnooze }: SnoozeDropdownPortalProps) {
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Calculate position based on button position
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const dropdownWidth = 120;
|
||||
|
||||
// Position dropdown below and aligned to right edge of button
|
||||
const left = Math.max(8, rect.right - dropdownWidth);
|
||||
const top = rect.bottom + 4;
|
||||
|
||||
setPosition({ top, left });
|
||||
}
|
||||
}, [isOpen, buttonRef]);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
|
||||
// Don't close if clicking the button or dropdown itself
|
||||
if (
|
||||
buttonRef.current?.contains(target) ||
|
||||
dropdownRef.current?.contains(target) ||
|
||||
target.closest('[data-snooze-dropdown]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Use capture phase to ensure we handle this before other click handlers
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => document.removeEventListener('click', handleClickOutside, true);
|
||||
}, [isOpen, onClose, buttonRef]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
data-snooze-dropdown
|
||||
className="fixed py-1 bg-stone-900 border border-stone-700 rounded-lg shadow-xl min-w-[120px] z-[9999] animate-fade-in"
|
||||
style={{ top: position.top, left: position.left }}>
|
||||
{SNOOZE_OPTIONS.map(option => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => onSnooze(option.duration)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-white hover:bg-stone-800 transition-colors cursor-pointer">
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// Source icons for different actionable item types
|
||||
const SOURCE_ICONS = {
|
||||
email: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
calendar: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
telegram: (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
),
|
||||
ai_insight: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
system: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
trading: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
security: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
function formatTimeAgo(date: Date): string {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7));
|
||||
|
||||
if (minutes < 1) return 'Just now';
|
||||
if (minutes < 60) return `${minutes} min${minutes === 1 ? '' : 's'} ago`;
|
||||
if (hours < 24) {
|
||||
if (hours === 1) return '1 hour ago';
|
||||
if (hours <= 6) return `${hours} hours ago`;
|
||||
if (hours <= 12) return 'This morning';
|
||||
return 'This afternoon';
|
||||
}
|
||||
if (days === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
if (weeks === 1) return '1 week ago';
|
||||
return `${weeks} weeks ago`;
|
||||
}
|
||||
|
||||
function isNewItem(date: Date): boolean {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
return diff < 5 * 60 * 1000; // Less than 5 minutes old
|
||||
}
|
||||
|
||||
export function ActionableCard({
|
||||
item,
|
||||
onComplete,
|
||||
onDismiss,
|
||||
onSnooze,
|
||||
className = '',
|
||||
}: ActionableCardProps) {
|
||||
const [showSnoozeMenu, setShowSnoozeMenu] = useState(false);
|
||||
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
||||
const snoozeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleComplete = () => {
|
||||
// Always let the parent handle completion logic
|
||||
// The parent (Intelligence.tsx) ALWAYS opens ChatModal for ALL tick actions
|
||||
onComplete(item);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
// Always let the parent handle dismiss logic and show confirmation modal
|
||||
// The parent (Intelligence.tsx) always shows confirmation for ALL dismiss actions
|
||||
onDismiss(item);
|
||||
};
|
||||
|
||||
const handleSnooze = (duration: number) => {
|
||||
setIsAnimatingOut(true);
|
||||
setTimeout(() => {
|
||||
onSnooze(item, duration);
|
||||
setShowSnoozeMenu(false);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
// Priority styling
|
||||
const priorityClasses = {
|
||||
critical: 'border-coral-500/30 bg-coral-500/5',
|
||||
important: 'border-amber-500/30 bg-amber-500/5',
|
||||
normal: 'border-white/10 bg-white/[0.02]',
|
||||
};
|
||||
|
||||
const priorityDotClasses = {
|
||||
critical: 'bg-coral-400',
|
||||
important: 'bg-amber-400',
|
||||
normal: 'bg-sage-400',
|
||||
};
|
||||
|
||||
const sourceIcon = item.icon || SOURCE_ICONS[item.source];
|
||||
const isNew = isNewItem(item.createdAt);
|
||||
const timeAgo = formatTimeAgo(item.createdAt);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
relative group transition-all duration-200 ease-in-out
|
||||
${isAnimatingOut ? 'opacity-0 translate-x-4 scale-95' : 'animate-fade-up'}
|
||||
${className}
|
||||
`}>
|
||||
<div
|
||||
className={`
|
||||
relative p-4 rounded-xl border backdrop-blur-sm transition-all duration-200
|
||||
hover:bg-white/[0.04] hover:border-white/20
|
||||
${priorityClasses[item.priority]}
|
||||
`}>
|
||||
{/* Main content row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white/70 flex-shrink-0 mt-0.5">
|
||||
{sourceIcon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-white leading-snug">{item.title}</h3>
|
||||
{item.description && (
|
||||
<p className="text-xs text-stone-400 mt-1 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Complete button */}
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-sage-400 hover:bg-sage-400/10 transition-all duration-150"
|
||||
title="Complete">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-coral-400 hover:bg-coral-400/10 transition-all duration-150"
|
||||
title="Dismiss">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Snooze button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={snoozeButtonRef}
|
||||
onClick={() => setShowSnoozeMenu(!showSnoozeMenu)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-md text-stone-400 hover:text-amber-400 hover:bg-amber-400/10 transition-all duration-150"
|
||||
title="Snooze">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${priorityDotClasses[item.priority]}`} />
|
||||
<span className="text-xs text-stone-500">{item.sourceLabel || item.source}</span>
|
||||
</div>
|
||||
<span className="text-xs text-stone-600">•</span>
|
||||
<span className="text-xs text-stone-500">{timeAgo}</span>
|
||||
{isNew && (
|
||||
<>
|
||||
<span className="text-xs text-stone-600">•</span>
|
||||
<span className="text-xs bg-sage-500 text-white px-1.5 py-0.5 rounded-sm font-medium">
|
||||
New
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Snooze dropdown portal - renders outside of any stacking context */}
|
||||
<SnoozeDropdownPortal
|
||||
isOpen={showSnoozeMenu}
|
||||
buttonRef={snoozeButtonRef}
|
||||
onClose={() => setShowSnoozeMenu(false)}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { ConfirmationModal as ConfirmationModalType } from '../../types/intelligence';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
modal: ConfirmationModalType;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmationModal({ modal, onClose }: ConfirmationModalProps) {
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
if (!modal.isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
modal.onConfirm();
|
||||
onClose();
|
||||
// TODO: Handle dontShowAgain preference storage
|
||||
if (dontShowAgain) {
|
||||
console.log('User chose to not show similar confirmations again');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
modal.onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 animate-fade-in"
|
||||
onClick={handleCancel}>
|
||||
<div
|
||||
className="bg-stone-900 rounded-2xl max-w-md w-full shadow-large border border-stone-700/50 animate-slide-up"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="p-6 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{modal.destructive && (
|
||||
<div className="w-10 h-10 rounded-full bg-coral-500/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg
|
||||
className="w-5 h-5 text-coral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold text-white">{modal.title}</h2>
|
||||
<p className="text-sm text-stone-400 mt-1">{modal.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't show again option */}
|
||||
{modal.showDontShowAgain && (
|
||||
<div className="px-6 pb-2">
|
||||
<label className="flex items-center gap-2 text-sm text-stone-400 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontShowAgain}
|
||||
onChange={e => setDontShowAgain(e.target.checked)}
|
||||
className="rounded border-stone-600 bg-stone-800 text-primary-500 focus:ring-primary-500 focus:ring-offset-0"
|
||||
/>
|
||||
Don't show similar suggestions
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-700/50">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-stone-400 hover:text-white rounded-lg hover:bg-stone-800 transition-colors">
|
||||
{modal.cancelText || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium rounded-lg transition-colors
|
||||
${
|
||||
modal.destructive
|
||||
? 'bg-coral-500 hover:bg-coral-600 text-white'
|
||||
: 'bg-primary-500 hover:bg-primary-600 text-white'
|
||||
}
|
||||
`}>
|
||||
{modal.confirmText || 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
|
||||
interface ToastProps {
|
||||
notification: ToastNotification;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const TOAST_ICONS = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const TOAST_STYLES = {
|
||||
success: 'bg-sage-500 text-white',
|
||||
error: 'bg-coral-500 text-white',
|
||||
warning: 'bg-amber-500 text-white',
|
||||
info: 'bg-primary-500 text-white',
|
||||
};
|
||||
|
||||
export function Toast({ notification, onRemove }: ToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
|
||||
const handleRemove = useCallback(() => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
onRemove(notification.id);
|
||||
}, 200);
|
||||
}, [onRemove, notification.id]);
|
||||
|
||||
useEffect(() => {
|
||||
// Animate in
|
||||
const showTimer = setTimeout(() => setIsVisible(true), 50);
|
||||
|
||||
// Auto remove after duration
|
||||
const duration = notification.duration || 4000;
|
||||
const removeTimer = setTimeout(() => {
|
||||
handleRemove();
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
clearTimeout(showTimer);
|
||||
clearTimeout(removeTimer);
|
||||
};
|
||||
}, [notification, handleRemove]);
|
||||
|
||||
const icon = TOAST_ICONS[notification.type];
|
||||
const styles = TOAST_STYLES[notification.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
transform transition-all duration-200 ease-in-out
|
||||
${isVisible && !isExiting ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
|
||||
${isExiting ? 'scale-95' : ''}
|
||||
`}>
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-3 p-4 rounded-lg shadow-large border backdrop-blur-sm
|
||||
max-w-sm w-full
|
||||
${styles}
|
||||
`}>
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">{icon}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium">{notification.title}</h4>
|
||||
{notification.message && (
|
||||
<p className="text-xs opacity-90 mt-1">{notification.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
{notification.action && (
|
||||
<button
|
||||
onClick={notification.action.handler}
|
||||
className="text-xs font-medium underline hover:no-underline flex-shrink-0">
|
||||
{notification.action.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="flex-shrink-0 text-white/70 hover:text-white transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
notifications: ToastNotification[];
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ToastContainer({ notifications, onRemove }: ToastContainerProps) {
|
||||
if (notifications.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
{notifications.map(notification => (
|
||||
<Toast key={notification.id} notification={notification} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Intelligence Components
|
||||
// Exports for the AI-powered actionable insights dashboard
|
||||
|
||||
export { ActionableCard } from './ActionableCard';
|
||||
export { ConfirmationModal } from './ConfirmationModal';
|
||||
export { Toast, ToastContainer } from './Toast';
|
||||
export { MOCK_ACTIONABLE_ITEMS } from './mockData';
|
||||
export { groupItemsByTime, filterItems, getItemStats } from './utils';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
ActionableItem,
|
||||
ActionableItemSource,
|
||||
ActionableItemPriority,
|
||||
ActionableItemStatus,
|
||||
TimeGroup,
|
||||
ToastNotification,
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
} from '../../types/intelligence';
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { ActionableItem } from '../../types/intelligence';
|
||||
|
||||
// Helper function to create dates relative to now
|
||||
function createDate(minutesAgo: number): Date {
|
||||
return new Date(Date.now() - minutesAgo * 60 * 1000);
|
||||
}
|
||||
|
||||
function createDateDays(daysAgo: number): Date {
|
||||
return new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
export const MOCK_ACTIONABLE_ITEMS: ActionableItem[] = [
|
||||
// Today - Fresh items
|
||||
{
|
||||
id: '1',
|
||||
title: 'Reply to 2 critical emails expecting response within 24hrs',
|
||||
description:
|
||||
'Messages from john@coinbase.com and sarah@ethereum.org about partnership proposals',
|
||||
source: 'email',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDate(2),
|
||||
updatedAt: createDate(2),
|
||||
expiresAt: createDate(-1440), // Expires in 24 hours
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Gmail',
|
||||
},
|
||||
|
||||
{
|
||||
id: '2',
|
||||
title: 'Meeting with Sarah in 30 minutes - prepare documents?',
|
||||
description:
|
||||
'Q4 strategy meeting. Need to review the crypto market analysis and portfolio updates.',
|
||||
source: 'calendar',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDate(5),
|
||||
updatedAt: createDate(5),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Calendar',
|
||||
},
|
||||
|
||||
{
|
||||
id: '3',
|
||||
title: 'Order lunch for your 2pm work session?',
|
||||
description:
|
||||
'You usually order from "Crypto Café" around this time. Your favorite: Bitcoin Bowl ($15.99)',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDate(15),
|
||||
updatedAt: createDate(15),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'AI Assistant',
|
||||
},
|
||||
|
||||
{
|
||||
id: '4',
|
||||
title: '5 unread messages from Alpha Trading Group',
|
||||
description:
|
||||
'@cryptowhale mentioned you about the ETH analysis. 3 other traders are discussing market trends.',
|
||||
source: 'telegram',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDate(25),
|
||||
updatedAt: createDate(25),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Telegram',
|
||||
},
|
||||
|
||||
{
|
||||
id: '5',
|
||||
title: 'Free 45min slot available for gym session',
|
||||
description: 'Your calendar shows a gap between 3:15-4:00 PM. LA Fitness has availability.',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDate(45),
|
||||
updatedAt: createDate(45),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'AI Planner',
|
||||
},
|
||||
|
||||
// Yesterday
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alex mentioned you in Alpha Human Development chat',
|
||||
description: '"@john can you review the new trading algorithm?" - 3 hours ago',
|
||||
source: 'telegram',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDate(180),
|
||||
updatedAt: createDate(180),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Telegram',
|
||||
},
|
||||
|
||||
{
|
||||
id: '7',
|
||||
title: 'Update available for DeFi trading bot',
|
||||
description:
|
||||
'Version 2.1.4 includes security patches and 15% better performance. Requires restart.',
|
||||
source: 'system',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDate(360),
|
||||
updatedAt: createDate(360),
|
||||
actionable: true,
|
||||
requiresConfirmation: true,
|
||||
sourceLabel: 'System',
|
||||
},
|
||||
|
||||
{
|
||||
id: '8',
|
||||
title: 'Backup your crypto wallet keys',
|
||||
description: 'Last backup was 30 days ago. Your portfolio is up 34% since then.',
|
||||
source: 'security',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDate(420),
|
||||
updatedAt: createDate(420),
|
||||
actionable: true,
|
||||
requiresConfirmation: false,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Security',
|
||||
},
|
||||
|
||||
// This week
|
||||
{
|
||||
id: '9',
|
||||
title: 'Schedule meeting with VCs for Series A discussion',
|
||||
description: 'Follow up on last weeks pitch. Andreessen Horowitz and Sequoia are interested.',
|
||||
source: 'email',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(2),
|
||||
updatedAt: createDateDays(2),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Gmail',
|
||||
},
|
||||
|
||||
{
|
||||
id: '10',
|
||||
title: 'Review 12 pending GitHub pull requests',
|
||||
description:
|
||||
'Features for v0.21.0 release. 8 from team members, 4 from community contributors.',
|
||||
source: 'system',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(3),
|
||||
updatedAt: createDateDays(3),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'GitHub',
|
||||
},
|
||||
|
||||
{
|
||||
id: '11',
|
||||
title: 'BTC price alert: Approaching your target of $65,000',
|
||||
description: 'Currently at $64,420. You set this alert 2 weeks ago. Consider taking profits?',
|
||||
source: 'trading',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(3),
|
||||
updatedAt: createDateDays(3),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Trading Bot',
|
||||
},
|
||||
|
||||
{
|
||||
id: '12',
|
||||
title: 'Complete KYC verification for Binance account',
|
||||
description: 'Required for withdrawal limits above $2,000. Upload ID and proof of address.',
|
||||
source: 'trading',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(4),
|
||||
updatedAt: createDateDays(4),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Binance',
|
||||
},
|
||||
|
||||
// Older items
|
||||
{
|
||||
id: '13',
|
||||
title: 'Respond to podcast interview request from Lex Fridman',
|
||||
description: 'Topic: "The Future of Decentralized Communication". Suggested dates: next month.',
|
||||
source: 'email',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(8),
|
||||
updatedAt: createDateDays(8),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Gmail',
|
||||
},
|
||||
|
||||
{
|
||||
id: '14',
|
||||
title: 'Submit tax documents for Q3 crypto transactions',
|
||||
description: '47 transactions across 5 exchanges. Deadline: October 15th.',
|
||||
source: 'system',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(10),
|
||||
updatedAt: createDateDays(10),
|
||||
expiresAt: createDate(-14400), // Expires in 10 days
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
requiresConfirmation: false,
|
||||
sourceLabel: 'TaxBot',
|
||||
},
|
||||
|
||||
{
|
||||
id: '15',
|
||||
title: 'Renew SSL certificate for api.alphahuman.com',
|
||||
description:
|
||||
'Certificate expires in 5 days. Automatic renewal failed - manual intervention required.',
|
||||
source: 'system',
|
||||
priority: 'critical',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(12),
|
||||
updatedAt: createDateDays(12),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
requiresConfirmation: false,
|
||||
sourceLabel: 'DevOps',
|
||||
},
|
||||
|
||||
{
|
||||
id: '16',
|
||||
title: 'Plan team building event for remote employees',
|
||||
description:
|
||||
'15 team members across 8 time zones. Budget approved: $5,000. Preference: virtual escape room.',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(14),
|
||||
updatedAt: createDateDays(14),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'HR Assistant',
|
||||
},
|
||||
|
||||
// Expired/old items that should show as low priority
|
||||
{
|
||||
id: '17',
|
||||
title: 'Update LinkedIn profile with latest achievements',
|
||||
description: 'Add recent TechCrunch feature and Y Combinator alumni status.',
|
||||
source: 'ai_insight',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(20),
|
||||
updatedAt: createDateDays(20),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Career AI',
|
||||
},
|
||||
|
||||
{
|
||||
id: '18',
|
||||
title: 'Review and approve marketing budget for Q4',
|
||||
description: '$50K allocated for influencer partnerships and conference sponsorships.',
|
||||
source: 'email',
|
||||
priority: 'important',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(21),
|
||||
updatedAt: createDateDays(21),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Team Email',
|
||||
},
|
||||
|
||||
{
|
||||
id: '19',
|
||||
title: 'Check server capacity for expected user growth',
|
||||
description: 'Daily active users grew 23% this month. Current infrastructure may need scaling.',
|
||||
source: 'system',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(25),
|
||||
updatedAt: createDateDays(25),
|
||||
actionable: true,
|
||||
hasComplexAction: false,
|
||||
sourceLabel: 'Monitoring',
|
||||
},
|
||||
|
||||
{
|
||||
id: '20',
|
||||
title: 'Organize crypto portfolio - consolidate across 7 wallets',
|
||||
description:
|
||||
'Assets spread across MetaMask, Ledger, Trust Wallet, and 4 others. Gas fees optimized for Sunday.',
|
||||
source: 'security',
|
||||
priority: 'normal',
|
||||
status: 'active',
|
||||
createdAt: createDateDays(30),
|
||||
updatedAt: createDateDays(30),
|
||||
actionable: true,
|
||||
hasComplexAction: true,
|
||||
sourceLabel: 'Portfolio AI',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ActionableItem, TimeGroup } from '../../types/intelligence';
|
||||
|
||||
/**
|
||||
* Groups actionable items by time periods (Today, Yesterday, This Week, Older)
|
||||
*/
|
||||
export function groupItemsByTime(items: ActionableItem[]): TimeGroup[] {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000);
|
||||
const oneWeekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const groups: Record<string, ActionableItem[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
thisWeek: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
items.forEach(item => {
|
||||
const itemDate = new Date(item.createdAt);
|
||||
const itemDateOnly = new Date(itemDate.getFullYear(), itemDate.getMonth(), itemDate.getDate());
|
||||
|
||||
if (itemDateOnly >= today) {
|
||||
groups.today.push(item);
|
||||
} else if (itemDateOnly >= yesterday) {
|
||||
groups.yesterday.push(item);
|
||||
} else if (itemDateOnly >= oneWeekAgo) {
|
||||
groups.thisWeek.push(item);
|
||||
} else {
|
||||
groups.older.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort items within each group by priority and then by date (newest first)
|
||||
const sortItems = (items: ActionableItem[]) => {
|
||||
const priorityOrder = { critical: 0, important: 1, normal: 2 };
|
||||
return items.sort((a, b) => {
|
||||
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
};
|
||||
|
||||
const timeGroups: TimeGroup[] = [];
|
||||
|
||||
if (groups.today.length > 0) {
|
||||
timeGroups.push({ label: 'Today', items: sortItems(groups.today), count: groups.today.length });
|
||||
}
|
||||
|
||||
if (groups.yesterday.length > 0) {
|
||||
timeGroups.push({
|
||||
label: 'Yesterday',
|
||||
items: sortItems(groups.yesterday),
|
||||
count: groups.yesterday.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.thisWeek.length > 0) {
|
||||
timeGroups.push({
|
||||
label: 'This Week',
|
||||
items: sortItems(groups.thisWeek),
|
||||
count: groups.thisWeek.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (groups.older.length > 0) {
|
||||
timeGroups.push({ label: 'Older', items: sortItems(groups.older), count: groups.older.length });
|
||||
}
|
||||
|
||||
return timeGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters items based on various criteria
|
||||
*/
|
||||
export function filterItems(
|
||||
items: ActionableItem[],
|
||||
options: { source?: string; priority?: string; status?: string; searchTerm?: string }
|
||||
): ActionableItem[] {
|
||||
let filtered = [...items];
|
||||
|
||||
if (options.source && options.source !== 'all') {
|
||||
filtered = filtered.filter(item => item.source === options.source);
|
||||
}
|
||||
|
||||
if (options.priority && options.priority !== 'all') {
|
||||
filtered = filtered.filter(item => item.priority === options.priority);
|
||||
}
|
||||
|
||||
if (options.status && options.status !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === options.status);
|
||||
}
|
||||
|
||||
if (options.searchTerm) {
|
||||
const term = options.searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
item =>
|
||||
item.title.toLowerCase().includes(term) ||
|
||||
item.description?.toLowerCase().includes(term) ||
|
||||
item.sourceLabel?.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets summary statistics for actionable items
|
||||
*/
|
||||
export function getItemStats(items: ActionableItem[]) {
|
||||
const total = items.length;
|
||||
const byPriority = items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.priority]++;
|
||||
return acc;
|
||||
},
|
||||
{ critical: 0, important: 0, normal: 0 }
|
||||
);
|
||||
|
||||
const bySource = items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.source] = (acc[item.source] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const newItems = items.filter(item => {
|
||||
const diff = Date.now() - item.createdAt.getTime();
|
||||
return diff < 5 * 60 * 1000; // Less than 5 minutes
|
||||
}).length;
|
||||
|
||||
const expiringSoon = items.filter(item => {
|
||||
if (!item.expiresAt) return false;
|
||||
const diff = item.expiresAt.getTime() - Date.now();
|
||||
return diff < 24 * 60 * 60 * 1000 && diff > 0; // Expires within 24 hours
|
||||
}).length;
|
||||
|
||||
return { total, byPriority, bySource, newItems, expiringSoon };
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { MOCK_ACTIONABLE_ITEMS } from '../components/intelligence/mockData';
|
||||
import { intelligenceApi, type ConnectedTool } from '../services/intelligenceApi';
|
||||
import type { ActionableItem, ActionableItemStatus } from '../types/intelligence';
|
||||
import { transformBackendItemsToFrontend, transformBackendMessagesToFrontend } from '../utils/intelligenceTransforms';
|
||||
|
||||
/**
|
||||
* Fallback implementation of Intelligence API hooks without React Query
|
||||
* Used when React Query is not available
|
||||
*/
|
||||
|
||||
interface UseActionableItemsResult {
|
||||
data: ActionableItem[] | undefined;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching actionable items (fallback version)
|
||||
* TODO: Remove MOCK_ACTIONABLE_ITEMS once backend APIs are ready
|
||||
*/
|
||||
export const useActionableItems = (options?: {
|
||||
refetchInterval?: number;
|
||||
enabled?: boolean;
|
||||
}): UseActionableItemsResult => {
|
||||
const [data, setData] = useState<ActionableItem[] | undefined>(MOCK_ACTIONABLE_ITEMS);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
if (options?.enabled === false) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const backendItems = await intelligenceApi.getActionableItems();
|
||||
const items = transformBackendItemsToFrontend(backendItems);
|
||||
setData(items.length > 0 ? items : MOCK_ACTIONABLE_ITEMS);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch items';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to fetch actionable items:', err);
|
||||
// TODO: Replace with actual data
|
||||
setData(MOCK_ACTIONABLE_ITEMS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [options?.enabled]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// Set up refetch interval
|
||||
useEffect(() => {
|
||||
if (options?.refetchInterval) {
|
||||
const interval = setInterval(fetchItems, options.refetchInterval);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [options?.refetchInterval, fetchItems]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchItems,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseUpdateActionableItemResult {
|
||||
mutateAsync: (variables: {
|
||||
itemId: string;
|
||||
status: ActionableItemStatus;
|
||||
}) => Promise<{ itemId: string; status: ActionableItemStatus; updatedAt: Date }>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for updating actionable item status (fallback version)
|
||||
*/
|
||||
export const useUpdateActionableItem = (): UseUpdateActionableItemResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (variables: {
|
||||
itemId: string;
|
||||
status: ActionableItemStatus;
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await intelligenceApi.updateItemStatus(variables.itemId, variables.status);
|
||||
return { ...variables, updatedAt: new Date() };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to update item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseSnoozeActionableItemResult {
|
||||
mutateAsync: (variables: {
|
||||
itemId: string;
|
||||
snoozeUntil: Date;
|
||||
}) => Promise<{ itemId: string; snoozeUntil: Date; updatedAt: Date }>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for snoozing actionable item (fallback version)
|
||||
*/
|
||||
export const useSnoozeActionableItem = (): UseSnoozeActionableItemResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (variables: {
|
||||
itemId: string;
|
||||
snoozeUntil: Date;
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await intelligenceApi.snoozeItem(variables.itemId, variables.snoozeUntil);
|
||||
return { ...variables, updatedAt: new Date() };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to snooze item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseChatSessionResult {
|
||||
data: {
|
||||
threadId: string;
|
||||
messages: any[];
|
||||
} | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for creating or getting chat session (fallback version)
|
||||
*/
|
||||
export const useChatSession = (itemId: string | null): UseChatSessionResult => {
|
||||
const [data, setData] = useState<{ threadId: string; messages: any[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemId) return;
|
||||
|
||||
const fetchSession = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await intelligenceApi.getOrCreateThread(itemId);
|
||||
setData({
|
||||
threadId: response.threadId,
|
||||
messages: transformBackendMessagesToFrontend(response.messages),
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to create session';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to create chat session:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSession();
|
||||
}, [itemId]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
interface UseExecuteTaskResult {
|
||||
mutateAsync: (variables: {
|
||||
itemId: string;
|
||||
connectedTools: ConnectedTool[];
|
||||
}) => Promise<{
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
status: string;
|
||||
}>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for executing tasks (fallback version)
|
||||
*/
|
||||
export const useExecuteTask = (): UseExecuteTaskResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutateAsync = useCallback(async (variables: {
|
||||
itemId: string;
|
||||
connectedTools: ConnectedTool[];
|
||||
}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await intelligenceApi.executeTask(variables.itemId, variables.connectedTools);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to execute task';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
mutateAsync,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
// Export query key utilities for consistency
|
||||
export const intelligenceKeys = {
|
||||
all: ['intelligence'] as const,
|
||||
items: () => [...intelligenceKeys.all, 'items'] as const,
|
||||
item: (id: string) => [...intelligenceKeys.all, 'item', id] as const,
|
||||
thread: (itemId: string) => [...intelligenceKeys.all, 'thread', itemId] as const,
|
||||
messages: (threadId: string) => [...intelligenceKeys.all, 'messages', threadId] as const,
|
||||
execution: (executionId: string) => [...intelligenceKeys.all, 'execution', executionId] as const,
|
||||
};
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { socketService } from '../services/socketService';
|
||||
import type { RootState } from '../store';
|
||||
import { addMessage, setExecutionResult, setTyping, updateExecutionProgress } from '../store/intelligenceSlice';
|
||||
import { createChatMessage } from '../utils/intelligenceTransforms';
|
||||
import { emitViaRustSocket } from '../utils/tauriSocket';
|
||||
|
||||
/**
|
||||
* WebSocket event payloads for Intelligence system
|
||||
*/
|
||||
interface ProcessMessagePayload {
|
||||
message: string;
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface AgentResponsePayload {
|
||||
message: string;
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
shouldExecute?: boolean;
|
||||
executionPlan?: any;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ExecutionProgressPayload {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
step: {
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
timestamp: string;
|
||||
};
|
||||
progress: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
timestamp?: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ExecutionCompletePayload {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
status: 'completed' | 'failed';
|
||||
result?: any;
|
||||
error?: string;
|
||||
artifacts?: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ChatInitPayload {
|
||||
tools: any[];
|
||||
threadId: string;
|
||||
sessionId?: string;
|
||||
context?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment for socket routing
|
||||
*/
|
||||
function isTauri(): boolean {
|
||||
try {
|
||||
return (window as any)?.__TAURI__ !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for Intelligence WebSocket integration
|
||||
* Handles real-time communication for chat and task execution
|
||||
*/
|
||||
export const useIntelligenceSocket = () => {
|
||||
const dispatch = useDispatch();
|
||||
const socket = socketService.getSocket();
|
||||
const eventHandlersRegistered = useRef(false);
|
||||
|
||||
// Get current socket connection status
|
||||
const isSocketConnected = useSelector((state: RootState) => {
|
||||
const userId = state.user.user?._id || '__pending__';
|
||||
return state.socket.byUser[userId]?.status === 'connected';
|
||||
});
|
||||
|
||||
/**
|
||||
* Send message to AI agent via WebSocket
|
||||
*/
|
||||
const sendMessage = useCallback(async (payload: ProcessMessagePayload) => {
|
||||
if (isTauri()) {
|
||||
// Use Rust socket for Tauri environment
|
||||
emitViaRustSocket('processMessageForUser', payload);
|
||||
} else if (socket?.connected) {
|
||||
socket.emit('processMessageForUser', payload);
|
||||
} else {
|
||||
console.warn('Cannot send message - socket not connected');
|
||||
throw new Error('Socket not connected');
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Initialize chat session with tools
|
||||
*/
|
||||
const sendChatInit = useCallback(async (payload: ChatInitPayload) => {
|
||||
if (isTauri()) {
|
||||
emitViaRustSocket('chat:init', payload);
|
||||
} else if (socket?.connected) {
|
||||
socket.emit('chat:init', payload);
|
||||
} else {
|
||||
console.warn('Cannot initialize chat - socket not connected');
|
||||
throw new Error('Socket not connected');
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Send typing indicator
|
||||
*/
|
||||
const sendTyping = useCallback((threadId: string, isTyping: boolean) => {
|
||||
const payload = { threadId, isTyping };
|
||||
|
||||
if (isTauri()) {
|
||||
emitViaRustSocket('chat:typing', payload);
|
||||
} else if (socket?.connected) {
|
||||
socket.emit('chat:typing', payload);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Register WebSocket event handlers
|
||||
*/
|
||||
const registerEventHandlers = useCallback(() => {
|
||||
if (!socket || eventHandlersRegistered.current) return;
|
||||
|
||||
// Agent response handler
|
||||
const handleAgentResponse = (data: AgentResponsePayload) => {
|
||||
console.log('Intelligence: Received agent response', {
|
||||
threadId: data.threadId,
|
||||
hasMessage: !!data.message,
|
||||
shouldExecute: data.shouldExecute,
|
||||
});
|
||||
|
||||
if (data.message && data.threadId) {
|
||||
const aiMessage = createChatMessage(data.message, 'ai');
|
||||
dispatch(addMessage({
|
||||
threadId: data.threadId,
|
||||
message: aiMessage,
|
||||
}));
|
||||
}
|
||||
|
||||
// Stop typing indicator
|
||||
dispatch(setTyping({
|
||||
threadId: data.threadId,
|
||||
isTyping: false,
|
||||
}));
|
||||
|
||||
// Handle execution trigger
|
||||
if (data.shouldExecute && data.executionPlan) {
|
||||
console.log('Intelligence: Execution requested', {
|
||||
threadId: data.threadId,
|
||||
plan: data.executionPlan,
|
||||
});
|
||||
// Execution will be handled by the component
|
||||
}
|
||||
};
|
||||
|
||||
// Execution progress handler
|
||||
const handleExecutionProgress = (data: ExecutionProgressPayload) => {
|
||||
console.log('Intelligence: Execution progress', {
|
||||
executionId: data.executionId,
|
||||
step: data.step?.label,
|
||||
status: data.step?.status,
|
||||
});
|
||||
|
||||
if (data.progress) {
|
||||
dispatch(updateExecutionProgress({
|
||||
executionId: data.executionId,
|
||||
progress: data.progress,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Execution complete handler
|
||||
const handleExecutionComplete = (data: ExecutionCompletePayload) => {
|
||||
console.log('Intelligence: Execution complete', {
|
||||
executionId: data.executionId,
|
||||
status: data.status,
|
||||
hasResult: !!data.result,
|
||||
hasArtifacts: !!data.artifacts?.length,
|
||||
});
|
||||
|
||||
dispatch(setExecutionResult({
|
||||
executionId: data.executionId,
|
||||
result: data.result,
|
||||
status: data.status,
|
||||
error: data.error,
|
||||
}));
|
||||
|
||||
// Send completion message if we have artifacts
|
||||
if (data.artifacts?.length && data.sessionId) {
|
||||
// This would need the threadId - we'd need to track execution to thread mapping
|
||||
// For now, we'll let the component handle the completion message
|
||||
console.log('Intelligence: Task completed with artifacts', {
|
||||
artifactCount: data.artifacts.length,
|
||||
sessionId: data.sessionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Typing indicator handler
|
||||
const handleTyping = (data: { threadId: string; isTyping: boolean }) => {
|
||||
dispatch(setTyping({
|
||||
threadId: data.threadId,
|
||||
isTyping: data.isTyping,
|
||||
}));
|
||||
};
|
||||
|
||||
// Register all handlers
|
||||
socket.on('agentResponse', handleAgentResponse);
|
||||
socket.on('execution:step_progress', handleExecutionProgress);
|
||||
socket.on('execution:complete', handleExecutionComplete);
|
||||
socket.on('chat:typing', handleTyping);
|
||||
|
||||
eventHandlersRegistered.current = true;
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
socket.off('agentResponse', handleAgentResponse);
|
||||
socket.off('execution:step_progress', handleExecutionProgress);
|
||||
socket.off('execution:complete', handleExecutionComplete);
|
||||
socket.off('chat:typing', handleTyping);
|
||||
eventHandlersRegistered.current = false;
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
/**
|
||||
* Register event handlers when socket is available
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (socket && isSocketConnected) {
|
||||
const cleanup = registerEventHandlers();
|
||||
return cleanup;
|
||||
}
|
||||
}, [socket, isSocketConnected, registerEventHandlers]);
|
||||
|
||||
/**
|
||||
* Cleanup on unmount
|
||||
*/
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
eventHandlersRegistered.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Connection status
|
||||
isConnected: isSocketConnected,
|
||||
|
||||
// Message sending
|
||||
sendMessage,
|
||||
sendChatInit,
|
||||
sendTyping,
|
||||
|
||||
// Utility functions
|
||||
isReady: isSocketConnected && !!socket,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing Intelligence WebSocket connection lifecycle
|
||||
*/
|
||||
export const useIntelligenceSocketManager = () => {
|
||||
const token = useSelector((state: RootState) => state.auth.token);
|
||||
const isConnected = useSelector((state: RootState) => {
|
||||
const userId = state.user.user?._id || '__pending__';
|
||||
return state.socket.byUser[userId]?.status === 'connected';
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize Intelligence socket connection
|
||||
*/
|
||||
const connect = useCallback(() => {
|
||||
if (token && !isConnected) {
|
||||
console.log('Intelligence: Initializing socket connection');
|
||||
socketService.connect(token);
|
||||
}
|
||||
}, [token, isConnected]);
|
||||
|
||||
/**
|
||||
* Disconnect Intelligence socket
|
||||
*/
|
||||
const disconnect = useCallback(() => {
|
||||
console.log('Intelligence: Disconnecting socket');
|
||||
socketService.disconnect();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Auto-connect when token is available
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (token && !isConnected) {
|
||||
connect();
|
||||
}
|
||||
}, [token, isConnected, connect]);
|
||||
|
||||
return {
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
isReady: isConnected && !!token,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for Intelligence-specific event subscriptions
|
||||
*/
|
||||
export const useIntelligenceEvents = () => {
|
||||
const socket = socketService.getSocket();
|
||||
|
||||
/**
|
||||
* Subscribe to agent responses for a specific thread
|
||||
*/
|
||||
const onAgentResponse = useCallback((
|
||||
threadId: string,
|
||||
callback: (data: AgentResponsePayload) => void
|
||||
) => {
|
||||
if (!socket) return () => {};
|
||||
|
||||
const handler = (data: AgentResponsePayload) => {
|
||||
if (data.threadId === threadId) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('agentResponse', handler);
|
||||
return () => socket.off('agentResponse', handler);
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Subscribe to execution progress for a specific execution
|
||||
*/
|
||||
const onExecutionProgress = useCallback((
|
||||
executionId: string,
|
||||
callback: (data: ExecutionProgressPayload) => void
|
||||
) => {
|
||||
if (!socket) return () => {};
|
||||
|
||||
const handler = (data: ExecutionProgressPayload) => {
|
||||
if (data.executionId === executionId) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('execution:step_progress', handler);
|
||||
return () => socket.off('execution:step_progress', handler);
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Subscribe to execution completion for a specific execution
|
||||
*/
|
||||
const onExecutionComplete = useCallback((
|
||||
executionId: string,
|
||||
callback: (data: ExecutionCompletePayload) => void
|
||||
) => {
|
||||
if (!socket) return () => {};
|
||||
|
||||
const handler = (data: ExecutionCompletePayload) => {
|
||||
if (data.executionId === executionId) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('execution:complete', handler);
|
||||
return () => socket.off('execution:complete', handler);
|
||||
}, [socket]);
|
||||
|
||||
return {
|
||||
onAgentResponse,
|
||||
onExecutionProgress,
|
||||
onExecutionComplete,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Chat tools initialization for Intelligence system.
|
||||
*
|
||||
* Provides functionality to gather all active skills' tools and send them
|
||||
* to the backend when a chat session is initialized, enabling AI to understand
|
||||
* available capabilities for task execution.
|
||||
*/
|
||||
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
|
||||
import debug from 'debug';
|
||||
|
||||
import type { ConnectedTool } from '../../services/intelligenceApi';
|
||||
import { socketService } from '../../services/socketService';
|
||||
import { store } from '../../store';
|
||||
import { emitViaRustSocket } from '../../utils/tauriSocket';
|
||||
import { transformMCPToConnectedTools } from '../../utils/intelligenceTransforms';
|
||||
import type { MCPTool } from '../mcp';
|
||||
import { deriveConnectionStatus } from '../skills/hooks';
|
||||
|
||||
// Chat tools logger using debug package
|
||||
const chatToolsLog = debug('chat:tools');
|
||||
const chatToolsWarn = debug('chat:tools:warn');
|
||||
|
||||
// Enable logging in development
|
||||
if (import.meta.env.DEV || import.meta.env.MODE === 'development') {
|
||||
debug.enable('chat:tools*');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in Tauri environment
|
||||
*/
|
||||
function isTauri(): boolean {
|
||||
try {
|
||||
return coreIsTauri();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for chat initialization payload
|
||||
*/
|
||||
export interface ChatInitPayload {
|
||||
tools: MCPTool[];
|
||||
connectedTools: ConnectedTool[];
|
||||
sessionId?: string;
|
||||
threadId?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gather all tools from active skills and send them to the backend
|
||||
* when initializing a chat session.
|
||||
*
|
||||
* This function:
|
||||
* 1. Retrieves all skills from Redux store
|
||||
* 2. Filters for active/ready skills with completed setup
|
||||
* 3. Extracts and formats tool definitions in both MCP and Connected formats
|
||||
* 4. Emits 'chat:init' event via appropriate socket method
|
||||
*
|
||||
* @param sessionId - Optional chat session identifier
|
||||
* @param threadId - Optional thread identifier for chat session
|
||||
* @param useConnectedFormat - Whether to send tools in Connected format (default: true)
|
||||
*/
|
||||
export function initializeChatWithTools(
|
||||
sessionId?: string,
|
||||
threadId?: string,
|
||||
useConnectedFormat: boolean = true
|
||||
): void {
|
||||
try {
|
||||
const state = store.getState();
|
||||
const skills = state.skills.skills;
|
||||
const skillStates = state.skills.skillStates;
|
||||
|
||||
chatToolsLog('Initializing chat with tools', {
|
||||
sessionId,
|
||||
threadId,
|
||||
useConnectedFormat,
|
||||
totalSkills: Object.keys(skills).length,
|
||||
});
|
||||
|
||||
const activeTools: MCPTool[] = [];
|
||||
|
||||
// Process each skill to extract active tools
|
||||
for (const [skillId, skill] of Object.entries(skills)) {
|
||||
// Derive connection status using existing logic
|
||||
const connectionStatus = deriveConnectionStatus(
|
||||
skill.status,
|
||||
skill.setupComplete,
|
||||
skillStates[skillId]
|
||||
);
|
||||
|
||||
// Only include tools from skills that are ready and properly connected
|
||||
const isSkillActive =
|
||||
(skill.status === 'ready' || skill.status === 'running') &&
|
||||
skill.setupComplete &&
|
||||
(connectionStatus === 'connected' || connectionStatus === 'connecting'); // Include connecting state for gradual initialization
|
||||
|
||||
if (isSkillActive && skill.tools?.length) {
|
||||
chatToolsLog('Processing tools for active skill', {
|
||||
skillId,
|
||||
skillName: skill.manifest.name,
|
||||
toolCount: skill.tools.length,
|
||||
status: skill.status,
|
||||
connectionStatus,
|
||||
});
|
||||
|
||||
// Transform skill tools to MCP format with skill prefix
|
||||
for (const tool of skill.tools) {
|
||||
activeTools.push({
|
||||
name: `${skillId}__${tool.name}`,
|
||||
description: `${skill.manifest.name}: ${tool.description}`,
|
||||
inputSchema: tool.inputSchema,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
chatToolsLog('Skipping inactive skill', {
|
||||
skillId,
|
||||
skillName: skill.manifest.name,
|
||||
status: skill.status,
|
||||
setupComplete: skill.setupComplete,
|
||||
connectionStatus,
|
||||
toolCount: skill.tools?.length || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create tools breakdown by skill for detailed logging
|
||||
const toolsBySkill: Record<string, { skillName: string; tools: string[] }> = {};
|
||||
activeTools.forEach(tool => {
|
||||
const [skillId] = tool.name.split('__');
|
||||
const skill = skills[skillId];
|
||||
if (skill) {
|
||||
if (!toolsBySkill[skillId]) {
|
||||
toolsBySkill[skillId] = { skillName: skill.manifest.name, tools: [] };
|
||||
}
|
||||
toolsBySkill[skillId].tools.push(tool.name.split('__')[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Transform to connected tools format if requested
|
||||
const connectedTools = useConnectedFormat ? transformMCPToConnectedTools(activeTools) : [];
|
||||
|
||||
// Prepare chat initialization payload
|
||||
const payload: ChatInitPayload = {
|
||||
tools: activeTools,
|
||||
connectedTools,
|
||||
sessionId,
|
||||
threadId,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Detailed logging of available tools
|
||||
chatToolsLog('🛠️ AVAILABLE TOOLS FOR CHAT INITIALIZATION', {
|
||||
sessionId,
|
||||
threadId,
|
||||
totalTools: activeTools.length,
|
||||
totalConnectedTools: connectedTools.length,
|
||||
totalSkills: Object.keys(toolsBySkill).length,
|
||||
useConnectedFormat,
|
||||
});
|
||||
|
||||
// Log tools breakdown by skill
|
||||
Object.entries(toolsBySkill).forEach(([skillId, { skillName, tools }]) => {
|
||||
chatToolsLog(`📦 ${skillName} (${skillId}):`, { toolCount: tools.length, tools });
|
||||
});
|
||||
|
||||
// Log complete tools list with descriptions
|
||||
if (activeTools.length > 0) {
|
||||
chatToolsLog(
|
||||
'📋 Complete Tools List:',
|
||||
activeTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description.slice(0, 80) + (tool.description.length > 80 ? '...' : ''),
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
chatToolsLog('⚠️ No active tools available - chat will have no tool capabilities');
|
||||
}
|
||||
|
||||
// Emit via appropriate socket method based on environment
|
||||
if (isTauri()) {
|
||||
chatToolsLog('Emitting chat:init via Rust socket');
|
||||
emitViaRustSocket('chat:init', payload);
|
||||
} else {
|
||||
chatToolsLog('Emitting chat:init via web socket');
|
||||
if (socketService.isConnected()) {
|
||||
socketService.emit('chat:init', payload);
|
||||
} else {
|
||||
chatToolsWarn('Socket not connected - chat initialization may be delayed');
|
||||
// Could potentially queue the initialization for when socket connects
|
||||
}
|
||||
}
|
||||
|
||||
chatToolsLog('Chat tools initialization completed', {
|
||||
sessionId,
|
||||
threadId,
|
||||
toolCount: activeTools.length,
|
||||
connectedToolCount: connectedTools.length,
|
||||
environment: isTauri() ? 'tauri' : 'web',
|
||||
});
|
||||
} catch (error) {
|
||||
chatToolsWarn('Failed to initialize chat with tools', {
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
// Don't throw - allow chat to continue without tools if needed
|
||||
// The backend should handle empty tools gracefully
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Intelligence chat session with both MCP and Connected tools formats
|
||||
* @param sessionId - Chat session identifier
|
||||
* @param threadId - Thread identifier for chat session
|
||||
* @param connectedTools - Pre-transformed connected tools (optional)
|
||||
*/
|
||||
export function initializeIntelligenceChatSession(
|
||||
sessionId: string,
|
||||
threadId: string,
|
||||
connectedTools?: ConnectedTool[]
|
||||
): void {
|
||||
try {
|
||||
const mcpTools = getCurrentActiveTools();
|
||||
const finalConnectedTools = connectedTools || transformMCPToConnectedTools(mcpTools);
|
||||
|
||||
const payload = {
|
||||
tools: finalConnectedTools, // Intelligence system expects Connected format
|
||||
sessionId,
|
||||
threadId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
chatToolsLog('Intelligence: Initializing chat session', {
|
||||
sessionId,
|
||||
threadId,
|
||||
toolCount: finalConnectedTools.length,
|
||||
});
|
||||
|
||||
// Emit via appropriate socket method based on environment
|
||||
if (isTauri()) {
|
||||
chatToolsLog('Intelligence: Emitting chat:init via Rust socket');
|
||||
emitViaRustSocket('chat:init', payload);
|
||||
} else {
|
||||
chatToolsLog('Intelligence: Emitting chat:init via web socket');
|
||||
if (socketService.isConnected()) {
|
||||
socketService.emit('chat:init', payload);
|
||||
} else {
|
||||
chatToolsWarn('Intelligence: Socket not connected - chat initialization may be delayed');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
chatToolsWarn('Intelligence: Failed to initialize chat session', {
|
||||
sessionId,
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tools without emitting (for debugging/display purposes)
|
||||
*/
|
||||
export function getCurrentActiveTools(): MCPTool[] {
|
||||
try {
|
||||
const state = store.getState();
|
||||
const skills = state.skills.skills;
|
||||
const skillStates = state.skills.skillStates;
|
||||
const activeTools: MCPTool[] = [];
|
||||
|
||||
for (const [skillId, skill] of Object.entries(skills)) {
|
||||
const connectionStatus = deriveConnectionStatus(
|
||||
skill.status,
|
||||
skill.setupComplete,
|
||||
skillStates[skillId]
|
||||
);
|
||||
|
||||
const isSkillActive =
|
||||
(skill.status === 'ready' || skill.status === 'running') &&
|
||||
skill.setupComplete &&
|
||||
(connectionStatus === 'connected' || connectionStatus === 'connecting');
|
||||
|
||||
if (isSkillActive && skill.tools?.length) {
|
||||
for (const tool of skill.tools) {
|
||||
activeTools.push({
|
||||
name: `${skillId}__${tool.name}`,
|
||||
description: `${skill.manifest.name}: ${tool.description}`,
|
||||
inputSchema: tool.inputSchema,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return activeTools;
|
||||
} catch (error) {
|
||||
chatToolsWarn('Failed to get current active tools', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active tools in Connected format for Intelligence system
|
||||
*/
|
||||
export function getCurrentConnectedTools(): ConnectedTool[] {
|
||||
try {
|
||||
const mcpTools = getCurrentActiveTools();
|
||||
return transformMCPToConnectedTools(mcpTools);
|
||||
} catch (error) {
|
||||
chatToolsWarn('Failed to get current connected tools', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
+375
-20
@@ -1,31 +1,386 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { ActionableCard } from '../components/intelligence/ActionableCard';
|
||||
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
|
||||
import { ToastContainer } from '../components/intelligence/Toast';
|
||||
import { filterItems, getItemStats, groupItemsByTime } from '../components/intelligence/utils';
|
||||
import {
|
||||
useActionableItems,
|
||||
useSnoozeActionableItem,
|
||||
useUpdateActionableItem,
|
||||
} from '../hooks/useIntelligenceApiFallback';
|
||||
import { useIntelligenceSocket, useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
|
||||
import { useIntelligenceStats } from '../hooks/useIntelligenceStats';
|
||||
import type { RootState } from '../store';
|
||||
import {
|
||||
setSourceFilter,
|
||||
setSearchFilter,
|
||||
} from '../store/intelligenceSlice';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemStatus,
|
||||
ConfirmationModal as ConfirmationModalType,
|
||||
ToastNotification,
|
||||
} from '../types/intelligence';
|
||||
|
||||
export default function Intelligence() {
|
||||
const dispatch = useDispatch();
|
||||
const { aiStatus } = useIntelligenceStats();
|
||||
|
||||
// Redux state
|
||||
const intelligenceState = useSelector((state: RootState) => state.intelligence);
|
||||
const { filters } = intelligenceState;
|
||||
|
||||
// API hooks
|
||||
const {
|
||||
data: apiItems,
|
||||
loading: itemsLoading,
|
||||
error: itemsError,
|
||||
refetch: refetchItems,
|
||||
} = useActionableItems();
|
||||
|
||||
const { mutateAsync: updateItemStatus } = useUpdateActionableItem();
|
||||
const { mutateAsync: snoozeItem } = useSnoozeActionableItem();
|
||||
|
||||
// Socket integration
|
||||
const socketManager = useIntelligenceSocketManager();
|
||||
const { isConnected: socketConnected } = useIntelligenceSocket();
|
||||
|
||||
// Local state for UI
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
const [confirmationModal, setConfirmationModal] = useState<ConfirmationModalType>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {},
|
||||
});
|
||||
|
||||
// Use API data or fallback to empty array
|
||||
const items = apiItems || [];
|
||||
|
||||
// Initialize socket connection
|
||||
useEffect(() => {
|
||||
if (!socketConnected) {
|
||||
socketManager.connect();
|
||||
}
|
||||
}, [socketConnected, socketManager]);
|
||||
|
||||
// Handle API errors with toast notifications
|
||||
useEffect(() => {
|
||||
if (itemsError) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Failed to load items',
|
||||
message: typeof itemsError === 'string' ? itemsError : 'Unable to fetch actionable items',
|
||||
});
|
||||
}
|
||||
}, [itemsError]);
|
||||
|
||||
// Filter and group items
|
||||
const filteredItems = useMemo(() => {
|
||||
const activeItems = items.filter(item => item.status === 'active');
|
||||
return filterItems(activeItems, {
|
||||
source: filters.source,
|
||||
priority: filters.priority,
|
||||
searchTerm: filters.search,
|
||||
});
|
||||
}, [items, filters.source, filters.priority, filters.search]);
|
||||
|
||||
const timeGroups = useMemo(() => {
|
||||
return groupItemsByTime(filteredItems);
|
||||
}, [filteredItems]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return getItemStats(filteredItems);
|
||||
}, [filteredItems]);
|
||||
|
||||
// Toast utilities
|
||||
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
|
||||
const newToast: ToastNotification = { ...toast, id: `toast-${Date.now()}-${Math.random()}` };
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
// Item action handlers with real backend integration
|
||||
const handleUpdateItemStatus = useCallback(async (itemId: string, status: ActionableItemStatus) => {
|
||||
try {
|
||||
await updateItemStatus({ itemId, status });
|
||||
|
||||
// Success toast
|
||||
let message = '';
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
message = 'Task marked as completed';
|
||||
break;
|
||||
case 'dismissed':
|
||||
message = 'Task dismissed';
|
||||
break;
|
||||
case 'active':
|
||||
message = 'Task reactivated';
|
||||
break;
|
||||
default:
|
||||
message = 'Status updated';
|
||||
}
|
||||
|
||||
addToast({
|
||||
type: 'success',
|
||||
title: 'Status Updated',
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update item status:', error);
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Update Failed',
|
||||
message: error instanceof Error ? error.message : 'Failed to update item status',
|
||||
});
|
||||
}
|
||||
}, [updateItemStatus]);
|
||||
|
||||
const handleComplete = useCallback(async (item: ActionableItem) => {
|
||||
await handleUpdateItemStatus(item.id, 'completed');
|
||||
}, [handleUpdateItemStatus]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(item: ActionableItem) => {
|
||||
// Always show confirmation modal for ALL dismiss actions
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
title: 'Dismiss item?',
|
||||
message: `Are you sure you want to dismiss "${item.title}"?`,
|
||||
confirmText: 'Dismiss',
|
||||
cancelText: 'Cancel',
|
||||
destructive: item.priority === 'critical',
|
||||
showDontShowAgain: !item.requiresConfirmation,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await handleUpdateItemStatus(item.id, 'dismissed');
|
||||
|
||||
// Add undo action to toast
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Dismissed',
|
||||
message: item.title.length > 40 ? `${item.title.substring(0, 40)}...` : item.title,
|
||||
action: {
|
||||
label: 'Undo',
|
||||
handler: () => handleUpdateItemStatus(item.id, 'active'),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to dismiss item:', error);
|
||||
}
|
||||
},
|
||||
onCancel: () => {},
|
||||
});
|
||||
},
|
||||
[handleUpdateItemStatus, addToast]
|
||||
);
|
||||
|
||||
const handleSnooze = useCallback(
|
||||
async (item: ActionableItem, duration: number) => {
|
||||
try {
|
||||
const snoozeUntil = new Date(Date.now() + duration);
|
||||
await snoozeItem({ itemId: item.id, snoozeUntil });
|
||||
|
||||
const hours = Math.round(duration / (1000 * 60 * 60));
|
||||
addToast({
|
||||
type: 'info',
|
||||
title: 'Snoozed',
|
||||
message: `Reminded in ${hours === 1 ? '1 hour' : `${hours} hours`}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to snooze item:', error);
|
||||
addToast({
|
||||
type: 'error',
|
||||
title: 'Snooze Failed',
|
||||
message: 'Failed to snooze item. Please try again.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[snoozeItem, addToast]
|
||||
);
|
||||
|
||||
// Combined AI and socket status indicator
|
||||
const systemStatus = socketConnected && aiStatus === 'ready' ? 'ready' :
|
||||
itemsLoading ? 'loading' :
|
||||
!socketConnected ? 'disconnected' : aiStatus;
|
||||
|
||||
const systemStatusLabel =
|
||||
systemStatus === 'ready' ? 'System Ready' :
|
||||
systemStatus === 'loading' ? 'Loading...' :
|
||||
systemStatus === 'disconnected' ? 'Connecting...' :
|
||||
systemStatus === 'initializing' ? 'Initializing...' :
|
||||
systemStatus === 'error' ? 'System Error' : 'System Idle';
|
||||
|
||||
const systemStatusDot =
|
||||
systemStatus === 'ready' ? 'bg-sage-400' :
|
||||
systemStatus === 'loading' ? 'bg-amber-400 animate-pulse' :
|
||||
systemStatus === 'disconnected' ? 'bg-amber-400 animate-pulse' :
|
||||
systemStatus === 'initializing' ? 'bg-amber-400 animate-pulse' :
|
||||
systemStatus === 'error' ? 'bg-coral-400' : 'bg-stone-600';
|
||||
|
||||
return (
|
||||
<div className="min-h-full relative">
|
||||
<div className="relative z-10 min-h-full flex flex-col items-center justify-center">
|
||||
<div className="max-w-md mx-auto text-center px-6">
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 mx-auto mb-6 rounded-2xl bg-white/[0.05] border border-white/[0.08] flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="relative z-10 min-h-full flex flex-col">
|
||||
<div className="flex-1 p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Intelligence</h1>
|
||||
{stats.total > 0 && (
|
||||
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
|
||||
{stats.total}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${systemStatusDot}`} />
|
||||
<span className="text-xs text-stone-400">{systemStatusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Intelligence</h1>
|
||||
<p className="text-stone-400 text-sm mb-6">
|
||||
AI-powered insights, memory, and analytics are coming soon.
|
||||
</p>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-6 animate-fade-up">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actionable items..."
|
||||
value={filters.search}
|
||||
onChange={e => dispatch(setSearchFilter(e.target.value))}
|
||||
className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={filters.source}
|
||||
onChange={e => dispatch(setSourceFilter(e.target.value as any))}
|
||||
className="px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500/50 transition-colors">
|
||||
<option value="all">All Sources</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="calendar">Calendar</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="ai_insight">AI Insights</option>
|
||||
<option value="system">System</option>
|
||||
<option value="trading">Trading</option>
|
||||
<option value="security">Security</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary-500/10 border border-primary-500/20">
|
||||
<div className="w-2 h-2 rounded-full bg-primary-400 animate-pulse" />
|
||||
<span className="text-xs font-medium text-primary-400">Coming Soon</span>
|
||||
{/* Content */}
|
||||
{itemsLoading ? (
|
||||
/* Loading State */
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Loading Intelligence...</h2>
|
||||
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
|
||||
</div>
|
||||
) : itemsError ? (
|
||||
/* Error State */
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-coral-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-coral-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Unable to load items</h2>
|
||||
<p className="text-stone-400 text-sm mb-4">
|
||||
{typeof itemsError === 'string' ? itemsError : 'Something went wrong'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchItems()}
|
||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : timeGroups.length === 0 ? (
|
||||
/* Empty State */
|
||||
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
|
||||
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
|
||||
<svg
|
||||
className="w-8 h-8 text-primary-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-white mb-2">All caught up!</h2>
|
||||
<p className="text-stone-400 text-sm">
|
||||
{filters.search || filters.source !== 'all'
|
||||
? 'No items match your current filters.'
|
||||
: 'No actionable items at the moment. Great work!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
/* Time Groups */
|
||||
<div className="space-y-6">
|
||||
{timeGroups.map((group, groupIndex) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50}ms` }}>
|
||||
{/* Group Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-semibold text-white opacity-80">{group.label}</h2>
|
||||
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
|
||||
{group.count}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
<div className="space-y-3">
|
||||
{group.items.map((item, itemIndex) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="animate-fade-up"
|
||||
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
|
||||
<ActionableCard
|
||||
item={item}
|
||||
onComplete={handleComplete}
|
||||
onDismiss={handleDismiss}
|
||||
onSnooze={handleSnooze}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast notifications */}
|
||||
<ToastContainer notifications={toasts} onRemove={removeToast} />
|
||||
|
||||
{/* Confirmation modal */}
|
||||
<ConfirmationModal
|
||||
modal={confirmationModal}
|
||||
onClose={() => setConfirmationModal(prev => ({ ...prev, isOpen: false }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { useIntelligenceSocketManager } from '../hooks/useIntelligenceSocket';
|
||||
import { setInitialized, setConnectionStatus } from '../store/intelligenceSlice';
|
||||
|
||||
/**
|
||||
* Intelligence context for managing system-wide Intelligence state
|
||||
*/
|
||||
interface IntelligenceContextValue {
|
||||
isInitialized: boolean;
|
||||
isConnected: boolean;
|
||||
initialize: () => void;
|
||||
}
|
||||
|
||||
const IntelligenceContext = createContext<IntelligenceContextValue | null>(null);
|
||||
|
||||
interface IntelligenceProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligence Provider - manages Intelligence system initialization and state
|
||||
*/
|
||||
export function IntelligenceProvider({ children }: IntelligenceProviderProps) {
|
||||
const dispatch = useDispatch();
|
||||
const socketManager = useIntelligenceSocketManager();
|
||||
|
||||
// Initialize Intelligence system
|
||||
useEffect(() => {
|
||||
dispatch(setInitialized(true));
|
||||
dispatch(setConnectionStatus(socketManager.isConnected ? 'connected' : 'connecting'));
|
||||
}, [dispatch, socketManager.isConnected]);
|
||||
|
||||
// Monitor connection status
|
||||
useEffect(() => {
|
||||
if (socketManager.isConnected) {
|
||||
dispatch(setConnectionStatus('connected'));
|
||||
} else {
|
||||
dispatch(setConnectionStatus('connecting'));
|
||||
}
|
||||
}, [dispatch, socketManager.isConnected]);
|
||||
|
||||
const contextValue: IntelligenceContextValue = {
|
||||
isInitialized: true,
|
||||
isConnected: socketManager.isConnected,
|
||||
initialize: socketManager.connect,
|
||||
};
|
||||
|
||||
return (
|
||||
<IntelligenceContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</IntelligenceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access Intelligence context
|
||||
*/
|
||||
export function useIntelligenceContext() {
|
||||
const context = useContext(IntelligenceContext);
|
||||
if (!context) {
|
||||
throw new Error('useIntelligenceContext must be used within IntelligenceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { ActionableItemStatus } from '../types/intelligence';
|
||||
import { apiClient } from './apiClient';
|
||||
|
||||
/**
|
||||
* Backend API response types for Intelligence system
|
||||
*/
|
||||
export interface BackendActionableItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
priority: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt?: string;
|
||||
snoozeUntil?: string;
|
||||
actionable: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
hasComplexAction?: boolean;
|
||||
dismissedAt?: string;
|
||||
completedAt?: string;
|
||||
reminderCount?: number;
|
||||
// Backend-specific fields
|
||||
threadId?: string;
|
||||
executionStatus?: 'idle' | 'running' | 'completed' | 'failed';
|
||||
currentSessionId?: string;
|
||||
}
|
||||
|
||||
export interface BackendChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
timestamp: string;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export interface BackendThreadResponse {
|
||||
threadId: string;
|
||||
messages: BackendChatMessage[];
|
||||
}
|
||||
|
||||
export interface BackendExecutionResponse {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
status: 'started' | 'running' | 'completed' | 'failed';
|
||||
}
|
||||
|
||||
export interface ConnectedTool {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, any>;
|
||||
skillId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligence API Service - handles all REST API calls for the Intelligence system
|
||||
*/
|
||||
export class IntelligenceApiService {
|
||||
/**
|
||||
* Get all actionable items from the backend
|
||||
*/
|
||||
async getActionableItems(): Promise<BackendActionableItem[]> {
|
||||
try {
|
||||
const response = await apiClient.get<{ items: BackendActionableItem[] }>('/telegram/actionable-items');
|
||||
return response.items || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch actionable items:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status of an actionable item
|
||||
*/
|
||||
async updateItemStatus(itemId: string, status: ActionableItemStatus): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/actionable-items/${itemId}`, { status });
|
||||
} catch (error) {
|
||||
console.error('Failed to update item status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze an actionable item until a specific time
|
||||
*/
|
||||
async snoozeItem(itemId: string, snoozeUntil: Date): Promise<void> {
|
||||
try {
|
||||
await apiClient.patch(`/actionable-items/${itemId}`, {
|
||||
status: 'snoozed',
|
||||
snoozeUntil: snoozeUntil.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to snooze item:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a conversation thread for an actionable item
|
||||
*/
|
||||
async getOrCreateThread(itemId: string): Promise<BackendThreadResponse> {
|
||||
try {
|
||||
const response = await apiClient.get<BackendThreadResponse>(`/${itemId}/thread`);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to get or create thread:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chat history for a specific thread
|
||||
*/
|
||||
async getChatHistory(threadId: string): Promise<BackendChatMessage[]> {
|
||||
try {
|
||||
const response = await apiClient.get<{ messages: BackendChatMessage[] }>(`/threads/${threadId}/messages`);
|
||||
return response.messages || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to get chat history:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start task execution for an actionable item with connected tools
|
||||
*/
|
||||
async executeTask(itemId: string, connectedTools: ConnectedTool[]): Promise<BackendExecutionResponse> {
|
||||
try {
|
||||
const response = await apiClient.post<BackendExecutionResponse>(`/${itemId}/execute`, {
|
||||
connectedTools
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to execute task:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get execution status for a specific execution ID
|
||||
*/
|
||||
async getExecutionStatus(executionId: string): Promise<{
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
progress?: any[];
|
||||
result?: any;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get(`/executions/${executionId}/status`);
|
||||
return response as {
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
progress?: any[];
|
||||
result?: any;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to get execution status:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running execution
|
||||
*/
|
||||
async cancelExecution(executionId: string): Promise<void> {
|
||||
try {
|
||||
await apiClient.post(`/executions/${executionId}/cancel`);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel execution:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const intelligenceApi = new IntelligenceApiService();
|
||||
@@ -19,6 +19,7 @@ import aiReducer from './aiSlice';
|
||||
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
import daemonReducer from './daemonSlice';
|
||||
import gmailReducer from './gmailSlice';
|
||||
import intelligenceReducer from './intelligenceSlice';
|
||||
import inviteReducer from './inviteSlice';
|
||||
import notionReducer from './notionSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
@@ -104,6 +105,7 @@ export const store = configureStore({
|
||||
gmail: gmailReducer,
|
||||
team: teamReducer,
|
||||
thread: persistedThreadReducer,
|
||||
intelligence: intelligenceReducer,
|
||||
invite: inviteReducer,
|
||||
notion: notionReducer,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { intelligenceApi } from '../services/intelligenceApi';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemSource,
|
||||
ActionableItemStatus,
|
||||
ChatMessage
|
||||
} from '../types/intelligence';
|
||||
import { transformBackendItemsToFrontend, transformBackendMessagesToFrontend } from '../utils/intelligenceTransforms';
|
||||
|
||||
/**
|
||||
* Chat session state for managing individual conversations
|
||||
*/
|
||||
export interface ChatSessionState {
|
||||
threadId: string;
|
||||
itemId: string;
|
||||
messages: ChatMessage[];
|
||||
isTyping: boolean;
|
||||
lastMessageId?: string;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execution state for tracking task progress
|
||||
*/
|
||||
export interface ExecutionState {
|
||||
executionId: string;
|
||||
sessionId: string;
|
||||
itemId: string;
|
||||
status: 'idle' | 'starting' | 'running' | 'completed' | 'failed';
|
||||
progress: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
timestamp?: Date;
|
||||
}>;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intelligence Redux state
|
||||
*/
|
||||
export interface IntelligenceState {
|
||||
// Actionable Items
|
||||
items: ActionableItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
|
||||
// Chat Sessions
|
||||
activeSessions: Record<string, ChatSessionState>;
|
||||
currentChatSession: string | null;
|
||||
|
||||
// Execution Management
|
||||
activeExecutions: Record<string, ExecutionState>;
|
||||
|
||||
// UI State
|
||||
filters: {
|
||||
source: ActionableItemSource | 'all';
|
||||
priority: 'critical' | 'important' | 'normal' | 'all';
|
||||
search: string;
|
||||
};
|
||||
|
||||
// System state
|
||||
initialized: boolean;
|
||||
connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
}
|
||||
|
||||
const initialState: IntelligenceState = {
|
||||
// Actionable Items
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
lastUpdate: null,
|
||||
|
||||
// Chat Sessions
|
||||
activeSessions: {},
|
||||
currentChatSession: null,
|
||||
|
||||
// Execution Management
|
||||
activeExecutions: {},
|
||||
|
||||
// UI State
|
||||
filters: {
|
||||
source: 'all',
|
||||
priority: 'all',
|
||||
search: '',
|
||||
},
|
||||
|
||||
// System state
|
||||
initialized: false,
|
||||
connectionStatus: 'disconnected',
|
||||
};
|
||||
|
||||
// Async thunks for API operations
|
||||
|
||||
/**
|
||||
* Fetch actionable items from backend
|
||||
*/
|
||||
export const fetchActionableItems = createAsyncThunk(
|
||||
'intelligence/fetchActionableItems',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const backendItems = await intelligenceApi.getActionableItems();
|
||||
return transformBackendItemsToFrontend(backendItems);
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to fetch actionable items'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Update item status
|
||||
*/
|
||||
export const updateItemStatus = createAsyncThunk(
|
||||
'intelligence/updateItemStatus',
|
||||
async (
|
||||
{ itemId, status }: { itemId: string; status: ActionableItemStatus },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
await intelligenceApi.updateItemStatus(itemId, status);
|
||||
return { itemId, status, updatedAt: new Date() };
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to update item status'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Snooze item until specific time
|
||||
*/
|
||||
export const snoozeItem = createAsyncThunk(
|
||||
'intelligence/snoozeItem',
|
||||
async (
|
||||
{ itemId, snoozeUntil }: { itemId: string; snoozeUntil: Date },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
await intelligenceApi.snoozeItem(itemId, snoozeUntil);
|
||||
return { itemId, snoozeUntil, updatedAt: new Date() };
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to snooze item'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Get or create chat session
|
||||
*/
|
||||
export const createChatSession = createAsyncThunk(
|
||||
'intelligence/createChatSession',
|
||||
async ({ itemId }: { itemId: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const threadResponse = await intelligenceApi.getOrCreateThread(itemId);
|
||||
const messages = transformBackendMessagesToFrontend(threadResponse.messages);
|
||||
|
||||
return {
|
||||
threadId: threadResponse.threadId,
|
||||
itemId,
|
||||
messages,
|
||||
};
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to create chat session'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Execute task with connected tools
|
||||
*/
|
||||
export const executeTask = createAsyncThunk(
|
||||
'intelligence/executeTask',
|
||||
async (
|
||||
{ itemId, connectedTools }: { itemId: string; connectedTools: any[] },
|
||||
{ rejectWithValue }
|
||||
) => {
|
||||
try {
|
||||
const response = await intelligenceApi.executeTask(itemId, connectedTools);
|
||||
return {
|
||||
executionId: response.executionId,
|
||||
sessionId: response.sessionId,
|
||||
itemId,
|
||||
status: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
return rejectWithValue(
|
||||
error && typeof error === 'object' && 'error' in error
|
||||
? error.error
|
||||
: 'Failed to execute task'
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Intelligence slice
|
||||
*/
|
||||
export const intelligenceSlice = createSlice({
|
||||
name: 'intelligence',
|
||||
initialState,
|
||||
reducers: {
|
||||
// System actions
|
||||
setInitialized: (state, action: PayloadAction<boolean>) => {
|
||||
state.initialized = action.payload;
|
||||
},
|
||||
|
||||
setConnectionStatus: (
|
||||
state,
|
||||
action: PayloadAction<'disconnected' | 'connecting' | 'connected' | 'error'>
|
||||
) => {
|
||||
state.connectionStatus = action.payload;
|
||||
},
|
||||
|
||||
// Items actions
|
||||
setItems: (state, action: PayloadAction<ActionableItem[]>) => {
|
||||
state.items = action.payload;
|
||||
state.lastUpdate = new Date();
|
||||
},
|
||||
|
||||
addItem: (state, action: PayloadAction<ActionableItem>) => {
|
||||
state.items.unshift(action.payload);
|
||||
state.lastUpdate = new Date();
|
||||
},
|
||||
|
||||
removeItem: (state, action: PayloadAction<string>) => {
|
||||
state.items = state.items.filter(item => item.id !== action.payload);
|
||||
state.lastUpdate = new Date();
|
||||
},
|
||||
|
||||
// Filters actions
|
||||
setSourceFilter: (state, action: PayloadAction<ActionableItemSource | 'all'>) => {
|
||||
state.filters.source = action.payload;
|
||||
},
|
||||
|
||||
setPriorityFilter: (state, action: PayloadAction<'critical' | 'important' | 'normal' | 'all'>) => {
|
||||
state.filters.priority = action.payload;
|
||||
},
|
||||
|
||||
setSearchFilter: (state, action: PayloadAction<string>) => {
|
||||
state.filters.search = action.payload;
|
||||
},
|
||||
|
||||
// Chat session actions
|
||||
setChatSession: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
itemId: string;
|
||||
messages?: ChatMessage[];
|
||||
}>
|
||||
) => {
|
||||
const { threadId, itemId, messages = [] } = action.payload;
|
||||
state.activeSessions[threadId] = {
|
||||
threadId,
|
||||
itemId,
|
||||
messages,
|
||||
isTyping: false,
|
||||
isConnected: true,
|
||||
};
|
||||
state.currentChatSession = threadId;
|
||||
},
|
||||
|
||||
addMessage: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
message: ChatMessage;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, message } = action.payload;
|
||||
const session = state.activeSessions[threadId];
|
||||
if (session) {
|
||||
session.messages.push(message);
|
||||
session.lastMessageId = message.id;
|
||||
}
|
||||
},
|
||||
|
||||
setTyping: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
threadId: string;
|
||||
isTyping: boolean;
|
||||
}>
|
||||
) => {
|
||||
const { threadId, isTyping } = action.payload;
|
||||
const session = state.activeSessions[threadId];
|
||||
if (session) {
|
||||
session.isTyping = isTyping;
|
||||
}
|
||||
},
|
||||
|
||||
closeChatSession: (state, action: PayloadAction<string>) => {
|
||||
const threadId = action.payload;
|
||||
delete state.activeSessions[threadId];
|
||||
if (state.currentChatSession === threadId) {
|
||||
state.currentChatSession = null;
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentChatSession: (state, action: PayloadAction<string | null>) => {
|
||||
state.currentChatSession = action.payload;
|
||||
},
|
||||
|
||||
// Execution actions
|
||||
setExecution: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
executionId: string;
|
||||
execution: ExecutionState;
|
||||
}>
|
||||
) => {
|
||||
const { executionId, execution } = action.payload;
|
||||
state.activeExecutions[executionId] = execution;
|
||||
},
|
||||
|
||||
updateExecutionProgress: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
executionId: string;
|
||||
progress: ExecutionState['progress'];
|
||||
}>
|
||||
) => {
|
||||
const { executionId, progress } = action.payload;
|
||||
const execution = state.activeExecutions[executionId];
|
||||
if (execution) {
|
||||
execution.progress = progress;
|
||||
execution.status = 'running';
|
||||
}
|
||||
},
|
||||
|
||||
setExecutionResult: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
executionId: string;
|
||||
result: any;
|
||||
status: 'completed' | 'failed';
|
||||
error?: string;
|
||||
}>
|
||||
) => {
|
||||
const { executionId, result, status, error } = action.payload;
|
||||
const execution = state.activeExecutions[executionId];
|
||||
if (execution) {
|
||||
execution.result = result;
|
||||
execution.status = status;
|
||||
execution.error = error;
|
||||
}
|
||||
},
|
||||
|
||||
clearExecution: (state, action: PayloadAction<string>) => {
|
||||
const executionId = action.payload;
|
||||
delete state.activeExecutions[executionId];
|
||||
},
|
||||
|
||||
// Error handling
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
// Fetch actionable items
|
||||
builder
|
||||
.addCase(fetchActionableItems.pending, (state) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchActionableItems.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.items = action.payload;
|
||||
state.lastUpdate = new Date();
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(fetchActionableItems.rejected, (state, action) => {
|
||||
state.loading = false;
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Update item status
|
||||
builder
|
||||
.addCase(updateItemStatus.fulfilled, (state, action) => {
|
||||
const { itemId, status, updatedAt } = action.payload;
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.status = status;
|
||||
item.updatedAt = updatedAt;
|
||||
|
||||
// Set completion/dismissal timestamps
|
||||
if (status === 'completed') {
|
||||
item.completedAt = updatedAt;
|
||||
} else if (status === 'dismissed') {
|
||||
item.dismissedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
state.lastUpdate = new Date();
|
||||
})
|
||||
.addCase(updateItemStatus.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Snooze item
|
||||
builder
|
||||
.addCase(snoozeItem.fulfilled, (state, action) => {
|
||||
const { itemId, snoozeUntil, updatedAt } = action.payload;
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.status = 'snoozed';
|
||||
item.snoozeUntil = snoozeUntil;
|
||||
item.updatedAt = updatedAt;
|
||||
item.reminderCount = (item.reminderCount || 0) + 1;
|
||||
}
|
||||
state.lastUpdate = new Date();
|
||||
})
|
||||
.addCase(snoozeItem.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Create chat session
|
||||
builder
|
||||
.addCase(createChatSession.fulfilled, (state, action) => {
|
||||
const { threadId, itemId, messages } = action.payload;
|
||||
state.activeSessions[threadId] = {
|
||||
threadId,
|
||||
itemId,
|
||||
messages,
|
||||
isTyping: false,
|
||||
isConnected: true,
|
||||
};
|
||||
state.currentChatSession = threadId;
|
||||
|
||||
// Update item with thread information
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.threadId = threadId;
|
||||
}
|
||||
})
|
||||
.addCase(createChatSession.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
|
||||
// Execute task
|
||||
builder
|
||||
.addCase(executeTask.fulfilled, (state, action) => {
|
||||
const { executionId, sessionId, itemId, status } = action.payload;
|
||||
state.activeExecutions[executionId] = {
|
||||
executionId,
|
||||
sessionId,
|
||||
itemId,
|
||||
status: status === 'started' ? 'running' : 'idle',
|
||||
progress: [],
|
||||
};
|
||||
|
||||
// Update item execution status
|
||||
const item = state.items.find(item => item.id === itemId);
|
||||
if (item) {
|
||||
item.executionStatus = 'running';
|
||||
item.currentSessionId = sessionId;
|
||||
}
|
||||
})
|
||||
.addCase(executeTask.rejected, (state, action) => {
|
||||
state.error = action.payload as string;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setInitialized,
|
||||
setConnectionStatus,
|
||||
setItems,
|
||||
addItem,
|
||||
removeItem,
|
||||
setSourceFilter,
|
||||
setPriorityFilter,
|
||||
setSearchFilter,
|
||||
setChatSession,
|
||||
addMessage,
|
||||
setTyping,
|
||||
closeChatSession,
|
||||
setCurrentChatSession,
|
||||
setExecution,
|
||||
updateExecutionProgress,
|
||||
setExecutionResult,
|
||||
clearExecution,
|
||||
clearError,
|
||||
} = intelligenceSlice.actions;
|
||||
|
||||
export default intelligenceSlice.reducer;
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Intelligence Chat API Types
|
||||
*
|
||||
* TypeScript definitions for backend integration
|
||||
* These types ensure consistency between frontend and backend implementations
|
||||
*/
|
||||
|
||||
// ===== Core Types =====
|
||||
|
||||
export type ConversationFlow =
|
||||
| 'discovery'
|
||||
| 'planning'
|
||||
| 'confirmation'
|
||||
| 'execution'
|
||||
| 'completion'
|
||||
| 'auto_close';
|
||||
|
||||
export type MessageType =
|
||||
| 'message'
|
||||
| 'plan'
|
||||
| 'progress'
|
||||
| 'completion'
|
||||
| 'discovery_question'
|
||||
| 'confirmation_request';
|
||||
|
||||
export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
|
||||
export type SessionStatus = 'active' | 'executing' | 'completed' | 'failed';
|
||||
|
||||
export type WorkflowType =
|
||||
| 'meeting_prep'
|
||||
| 'email_response'
|
||||
| 'system_task'
|
||||
| 'document_analysis'
|
||||
| 'calendar_management';
|
||||
|
||||
// ===== Request/Response Types =====
|
||||
|
||||
export interface CreateChatSessionRequest {
|
||||
actionable_item_id: string;
|
||||
user_id: string;
|
||||
context: { item_type: WorkflowType; metadata: Record<string, any> };
|
||||
}
|
||||
|
||||
export interface CreateChatSessionResponse {
|
||||
success: true;
|
||||
data: {
|
||||
session_id: string;
|
||||
initial_message: {
|
||||
content: string;
|
||||
type: MessageType;
|
||||
options: string[];
|
||||
context: Record<string, any>;
|
||||
};
|
||||
expected_flow: ConversationFlow[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sender: 'ai' | 'user';
|
||||
timestamp: string; // ISO 8601
|
||||
type: MessageType;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChatSessionDetails {
|
||||
session_id: string;
|
||||
status: SessionStatus;
|
||||
current_flow: ConversationFlow;
|
||||
messages: ChatMessage[];
|
||||
context: Record<string, any>;
|
||||
execution_id?: string;
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
content: string;
|
||||
sender: 'user';
|
||||
current_flow: ConversationFlow;
|
||||
context: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SendMessageResponse {
|
||||
success: true;
|
||||
data: {
|
||||
ai_response: {
|
||||
content: string;
|
||||
type: MessageType;
|
||||
next_flow: ConversationFlow;
|
||||
options: string[];
|
||||
metadata: Record<string, any>;
|
||||
};
|
||||
should_execute: boolean;
|
||||
execution_plan?: ExecutionPlan;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Execution Types =====
|
||||
|
||||
export interface ExecutionStep {
|
||||
id: string;
|
||||
label: string;
|
||||
status: TaskStatus;
|
||||
estimated_duration: number;
|
||||
dependencies: string[];
|
||||
started_at?: string; // ISO 8601
|
||||
completed_at?: string; // ISO 8601
|
||||
progress_percentage?: number;
|
||||
result?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ExecutionPlan {
|
||||
id: string;
|
||||
steps: ExecutionStep[];
|
||||
estimated_total_duration: number;
|
||||
requirements: { gmail_access?: boolean; notion_access?: boolean; calendar_access?: boolean };
|
||||
}
|
||||
|
||||
export interface StartExecutionRequest {
|
||||
execution_plan_id: string;
|
||||
confirmed: true;
|
||||
modifications?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StartExecutionResponse {
|
||||
success: true;
|
||||
data: {
|
||||
execution_id: string;
|
||||
status: 'started';
|
||||
estimated_duration: number;
|
||||
steps: ExecutionStep[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionStatusResponse {
|
||||
success: true;
|
||||
data: {
|
||||
execution_id: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
current_step?: { id: string; label: string; status: TaskStatus; progress_percentage: number };
|
||||
completed_steps: string[];
|
||||
results?: ExecutionResults;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionResults {
|
||||
summary: string;
|
||||
artifacts: Artifact[];
|
||||
metrics: {
|
||||
total_duration: number;
|
||||
emails_processed?: number;
|
||||
documents_created?: number;
|
||||
apis_called?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
type: 'notion_doc' | 'email_draft' | 'calendar_event' | 'document' | 'link';
|
||||
title: string;
|
||||
url: string;
|
||||
created_at: string; // ISO 8601
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// ===== WebSocket Event Types =====
|
||||
|
||||
export interface WebSocketMessage<T = any> {
|
||||
type: string;
|
||||
data: T;
|
||||
timestamp?: string; // ISO 8601
|
||||
}
|
||||
|
||||
// Tool definition for chat initialization
|
||||
export interface ChatTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: { type: 'object'; properties: Record<string, any>; required?: string[] };
|
||||
}
|
||||
|
||||
// Client → Server Events
|
||||
export interface ChatInitEvent {
|
||||
type: 'chat:init';
|
||||
data: { tools: ChatTool[]; sessionId?: string; timestamp: number };
|
||||
}
|
||||
|
||||
export interface AuthenticateEvent {
|
||||
type: 'authenticate';
|
||||
data: { token: string; session_id: string };
|
||||
}
|
||||
|
||||
export interface JoinSessionEvent {
|
||||
type: 'join_session';
|
||||
data: { session_id: string };
|
||||
}
|
||||
|
||||
// Server → Client Events
|
||||
export interface AuthenticatedEvent {
|
||||
type: 'authenticated';
|
||||
data: { user_id: string; session_id: string };
|
||||
}
|
||||
|
||||
export interface ExecutionStepProgressEvent {
|
||||
type: 'execution_step_progress';
|
||||
data: {
|
||||
session_id: string;
|
||||
execution_id: string;
|
||||
step_id: string;
|
||||
status: TaskStatus;
|
||||
progress_percentage: number;
|
||||
message: string;
|
||||
timestamp: string; // ISO 8601
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionCompleteEvent {
|
||||
type: 'execution_complete';
|
||||
data: {
|
||||
session_id: string;
|
||||
execution_id: string;
|
||||
status: 'completed' | 'failed';
|
||||
results: ExecutionResults;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AIThinkingEvent {
|
||||
type: 'ai_thinking';
|
||||
data: { session_id: string; message: string; estimated_time: number };
|
||||
}
|
||||
|
||||
export interface ErrorEvent {
|
||||
type: 'error';
|
||||
data: {
|
||||
session_id: string;
|
||||
error_code: ErrorCode;
|
||||
message: string;
|
||||
details: Record<string, any>;
|
||||
retry_after?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Error Types =====
|
||||
|
||||
export type ErrorCode =
|
||||
| 'SESSION_NOT_FOUND'
|
||||
| 'EXECUTION_FAILED'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INVALID_INPUT'
|
||||
| 'SOURCE_UNAVAILABLE'
|
||||
| 'AI_UNAVAILABLE'
|
||||
| 'TIMEOUT'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface APIError {
|
||||
code: ErrorCode;
|
||||
message: string;
|
||||
details: Record<string, any>;
|
||||
}
|
||||
|
||||
// ===== Standard API Response Envelope =====
|
||||
|
||||
export interface APIResponse<T = any> {
|
||||
success: boolean;
|
||||
data: T | null;
|
||||
error: APIError | null;
|
||||
meta: {
|
||||
timestamp: string; // ISO 8601
|
||||
request_id: string;
|
||||
rate_limit: {
|
||||
remaining: number;
|
||||
reset_at: string; // ISO 8601
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Meeting Preparation Specific Types =====
|
||||
|
||||
export interface MeetingPrepWorkflow {
|
||||
workflow_type: 'meeting_preparation';
|
||||
inputs: {
|
||||
meeting_title: string;
|
||||
participant: string;
|
||||
time_context: string;
|
||||
document_sources: ('gmail' | 'notion' | 'calendar')[];
|
||||
output_format: 'notion_doc' | 'email_summary' | 'both';
|
||||
};
|
||||
processing_steps: MeetingPrepStep[];
|
||||
}
|
||||
|
||||
export interface MeetingPrepStep {
|
||||
step: 'fetch_gmail' | 'access_notion' | 'analyze_context' | 'consolidate_docs' | 'generate_link';
|
||||
action: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
// ===== Service Integration Types =====
|
||||
|
||||
export interface ExternalServiceConfig {
|
||||
gmail?: { enabled: boolean; scopes: string[]; rate_limit: number };
|
||||
notion?: { enabled: boolean; workspace_id: string; rate_limit: number };
|
||||
calendar?: { enabled: boolean; calendar_ids: string[] };
|
||||
}
|
||||
|
||||
export interface ServiceAuthStatus {
|
||||
gmail: 'connected' | 'disconnected' | 'error';
|
||||
notion: 'connected' | 'disconnected' | 'error';
|
||||
calendar: 'connected' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
// ===== Performance & Monitoring Types =====
|
||||
|
||||
export interface PerformanceMetrics {
|
||||
response_time: number;
|
||||
execution_time: number;
|
||||
external_api_calls: number;
|
||||
tokens_used: number;
|
||||
cost_estimate: number;
|
||||
}
|
||||
|
||||
export interface SessionMetrics {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
started_at: string; // ISO 8601
|
||||
ended_at?: string; // ISO 8601
|
||||
total_messages: number;
|
||||
execution_count: number;
|
||||
performance: PerformanceMetrics;
|
||||
satisfaction_score?: number;
|
||||
}
|
||||
|
||||
// ===== Type Guards =====
|
||||
|
||||
export function isWebSocketMessage<T>(obj: any): obj is WebSocketMessage<T> {
|
||||
return typeof obj === 'object' && obj !== null && typeof obj.type === 'string' && 'data' in obj;
|
||||
}
|
||||
|
||||
export function isAPIResponse<T>(obj: any): obj is APIResponse<T> {
|
||||
return (
|
||||
typeof obj === 'object' &&
|
||||
obj !== null &&
|
||||
typeof obj.success === 'boolean' &&
|
||||
('data' in obj || 'error' in obj) &&
|
||||
'meta' in obj
|
||||
);
|
||||
}
|
||||
|
||||
export function isExecutionStepProgressEvent(obj: any): obj is ExecutionStepProgressEvent {
|
||||
return (
|
||||
isWebSocketMessage(obj) &&
|
||||
obj.type === 'execution_step_progress' &&
|
||||
typeof obj.data === 'object' &&
|
||||
obj.data !== null &&
|
||||
'step_id' in obj.data &&
|
||||
'progress_percentage' in obj.data
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Utility Types =====
|
||||
|
||||
export type ChatEventHandler<T = any> = (event: WebSocketMessage<T>) => void;
|
||||
|
||||
export interface ChatClientConfig {
|
||||
websocket_url: string;
|
||||
api_base_url: string;
|
||||
auth_token: string;
|
||||
retry_attempts: number;
|
||||
timeout_ms: number;
|
||||
}
|
||||
|
||||
// ===== Frontend State Types =====
|
||||
|
||||
export interface ChatState {
|
||||
session: ChatSessionDetails | null;
|
||||
messages: ChatMessage[];
|
||||
currentFlow: ConversationFlow;
|
||||
isExecuting: boolean;
|
||||
executionProgress: ExecutionStep[];
|
||||
isConnected: boolean;
|
||||
lastError: APIError | null;
|
||||
}
|
||||
|
||||
export interface ChatActions {
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
startExecution: (planId: string) => Promise<void>;
|
||||
connect: () => Promise<void>;
|
||||
disconnect: () => void;
|
||||
clearError: () => void;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Intelligence System Types
|
||||
// Actionable items and AI insights for the Intelligence page
|
||||
import type React from 'react';
|
||||
|
||||
export type ActionableItemSource =
|
||||
| 'email'
|
||||
| 'calendar'
|
||||
| 'telegram'
|
||||
| 'ai_insight'
|
||||
| 'system'
|
||||
| 'trading'
|
||||
| 'security';
|
||||
|
||||
export type ActionableItemPriority = 'critical' | 'important' | 'normal';
|
||||
|
||||
export type ActionableItemStatus = 'active' | 'dismissed' | 'completed' | 'snoozed';
|
||||
|
||||
export interface ActionableItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
source: ActionableItemSource;
|
||||
priority: ActionableItemPriority;
|
||||
status: ActionableItemStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
expiresAt?: Date;
|
||||
snoozeUntil?: Date;
|
||||
|
||||
// Action metadata
|
||||
actionable: boolean;
|
||||
requiresConfirmation?: boolean;
|
||||
hasComplexAction?: boolean;
|
||||
|
||||
// Visual presentation
|
||||
icon?: React.ReactElement;
|
||||
sourceLabel?: string;
|
||||
|
||||
// Interaction tracking
|
||||
dismissedAt?: Date;
|
||||
completedAt?: Date;
|
||||
reminderCount?: number;
|
||||
|
||||
// Backend integration fields
|
||||
threadId?: string;
|
||||
executionStatus?: 'idle' | 'running' | 'completed' | 'failed';
|
||||
currentSessionId?: string;
|
||||
}
|
||||
|
||||
export interface ActionableItemAction {
|
||||
type: 'complete' | 'dismiss' | 'snooze';
|
||||
timestamp: Date;
|
||||
itemId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TimeGroup {
|
||||
label: string;
|
||||
items: ActionableItem[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IntelligencePageState {
|
||||
items: ActionableItem[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastUpdate: Date | null;
|
||||
|
||||
// UI state
|
||||
showCompleted: boolean;
|
||||
filter: ActionableItemSource | 'all';
|
||||
}
|
||||
|
||||
// Snooze time options
|
||||
export type SnoozeOption = {
|
||||
label: string;
|
||||
duration: number; // milliseconds
|
||||
customTime?: Date;
|
||||
};
|
||||
|
||||
// Toast notification types
|
||||
export interface ToastNotification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
action?: { label: string; handler: () => void };
|
||||
}
|
||||
|
||||
// Confirmation modal data
|
||||
export interface ConfirmationModal {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
destructive?: boolean;
|
||||
showDontShowAgain?: boolean;
|
||||
}
|
||||
|
||||
// Chat message type
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
sender: 'user' | 'ai';
|
||||
timestamp: Date;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { MCPTool } from '../lib/mcp';
|
||||
import type {
|
||||
BackendActionableItem,
|
||||
BackendChatMessage,
|
||||
ConnectedTool
|
||||
} from '../services/intelligenceApi';
|
||||
import type {
|
||||
ActionableItem,
|
||||
ActionableItemPriority,
|
||||
ActionableItemSource,
|
||||
ActionableItemStatus,
|
||||
ChatMessage
|
||||
} from '../types/intelligence';
|
||||
|
||||
/**
|
||||
* Transform backend actionable item to frontend format
|
||||
*/
|
||||
export function transformBackendItemToFrontend(backendItem: BackendActionableItem): ActionableItem {
|
||||
return {
|
||||
id: backendItem.id,
|
||||
title: backendItem.title,
|
||||
description: backendItem.description,
|
||||
source: backendItem.source as ActionableItemSource,
|
||||
priority: backendItem.priority as ActionableItemPriority,
|
||||
status: backendItem.status as ActionableItemStatus,
|
||||
createdAt: new Date(backendItem.createdAt),
|
||||
updatedAt: new Date(backendItem.updatedAt),
|
||||
expiresAt: backendItem.expiresAt ? new Date(backendItem.expiresAt) : undefined,
|
||||
snoozeUntil: backendItem.snoozeUntil ? new Date(backendItem.snoozeUntil) : undefined,
|
||||
actionable: backendItem.actionable,
|
||||
requiresConfirmation: backendItem.requiresConfirmation,
|
||||
hasComplexAction: backendItem.hasComplexAction,
|
||||
dismissedAt: backendItem.dismissedAt ? new Date(backendItem.dismissedAt) : undefined,
|
||||
completedAt: backendItem.completedAt ? new Date(backendItem.completedAt) : undefined,
|
||||
reminderCount: backendItem.reminderCount,
|
||||
// Backend integration fields
|
||||
threadId: backendItem.threadId,
|
||||
executionStatus: backendItem.executionStatus,
|
||||
currentSessionId: backendItem.currentSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform multiple backend items to frontend format
|
||||
*/
|
||||
export function transformBackendItemsToFrontend(backendItems: BackendActionableItem[]): ActionableItem[] {
|
||||
return backendItems.map(transformBackendItemToFrontend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform backend chat message to frontend format
|
||||
*/
|
||||
export function transformBackendMessageToFrontend(backendMessage: BackendChatMessage): ChatMessage {
|
||||
return {
|
||||
id: backendMessage.id,
|
||||
content: backendMessage.content,
|
||||
sender: backendMessage.role === 'user' ? 'user' : 'ai',
|
||||
timestamp: new Date(backendMessage.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform multiple backend messages to frontend format
|
||||
*/
|
||||
export function transformBackendMessagesToFrontend(backendMessages: BackendChatMessage[]): ChatMessage[] {
|
||||
return backendMessages.map(transformBackendMessageToFrontend);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform frontend chat message to backend format
|
||||
*/
|
||||
export function transformFrontendMessageToBackend(
|
||||
message: ChatMessage,
|
||||
threadId: string
|
||||
): Omit<BackendChatMessage, 'id'> {
|
||||
return {
|
||||
content: message.content,
|
||||
role: message.sender === 'user' ? 'user' : 'assistant',
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
threadId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform MCP tools to connected tools format for backend
|
||||
*/
|
||||
export function transformMCPToConnectedTools(mcpTools: MCPTool[]): ConnectedTool[] {
|
||||
return mcpTools.map(tool => {
|
||||
const [skillId, toolName] = tool.name.split('__');
|
||||
|
||||
return {
|
||||
name: toolName || tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema || {},
|
||||
skillId: skillId || 'unknown',
|
||||
enabled: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform connected tools back to MCP format
|
||||
*/
|
||||
export function transformConnectedToolsToMCP(connectedTools: ConnectedTool[]): MCPTool[] {
|
||||
return connectedTools.map(tool => ({
|
||||
name: `${tool.skillId}__${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: tool.parameters || {},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate actionable item data from backend
|
||||
*/
|
||||
export function validateBackendItem(item: any): item is BackendActionableItem {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof item.id === 'string' &&
|
||||
typeof item.title === 'string' &&
|
||||
typeof item.source === 'string' &&
|
||||
typeof item.priority === 'string' &&
|
||||
typeof item.status === 'string' &&
|
||||
typeof item.createdAt === 'string' &&
|
||||
typeof item.updatedAt === 'string' &&
|
||||
typeof item.actionable === 'boolean'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat message in frontend format
|
||||
*/
|
||||
export function createChatMessage(
|
||||
content: string,
|
||||
sender: 'user' | 'ai',
|
||||
id?: string
|
||||
): ChatMessage {
|
||||
return {
|
||||
id: id || `${sender}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
content,
|
||||
sender,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map execution status to user-friendly labels
|
||||
*/
|
||||
export function getExecutionStatusLabel(status?: string): string {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'Ready';
|
||||
case 'running':
|
||||
return 'In Progress';
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority display information
|
||||
*/
|
||||
export function getPriorityInfo(priority: ActionableItemPriority): {
|
||||
label: string;
|
||||
className: string;
|
||||
color: string;
|
||||
} {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return {
|
||||
label: 'Critical',
|
||||
className: 'text-coral-400 bg-coral-500/10',
|
||||
color: 'coral',
|
||||
};
|
||||
case 'important':
|
||||
return {
|
||||
label: 'Important',
|
||||
className: 'text-amber-400 bg-amber-500/10',
|
||||
color: 'amber',
|
||||
};
|
||||
case 'normal':
|
||||
return {
|
||||
label: 'Normal',
|
||||
className: 'text-sage-400 bg-sage-500/10',
|
||||
color: 'sage',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source display information
|
||||
*/
|
||||
export function getSourceInfo(source: ActionableItemSource): {
|
||||
label: string;
|
||||
icon: string;
|
||||
className: string;
|
||||
} {
|
||||
switch (source) {
|
||||
case 'email':
|
||||
return {
|
||||
label: 'Email',
|
||||
icon: '📧',
|
||||
className: 'text-blue-400 bg-blue-500/10',
|
||||
};
|
||||
case 'calendar':
|
||||
return {
|
||||
label: 'Calendar',
|
||||
icon: '📅',
|
||||
className: 'text-green-400 bg-green-500/10',
|
||||
};
|
||||
case 'telegram':
|
||||
return {
|
||||
label: 'Telegram',
|
||||
icon: '💬',
|
||||
className: 'text-blue-400 bg-blue-500/10',
|
||||
};
|
||||
case 'ai_insight':
|
||||
return {
|
||||
label: 'AI Insight',
|
||||
icon: '🤖',
|
||||
className: 'text-purple-400 bg-purple-500/10',
|
||||
};
|
||||
case 'system':
|
||||
return {
|
||||
label: 'System',
|
||||
icon: '⚙️',
|
||||
className: 'text-stone-400 bg-stone-500/10',
|
||||
};
|
||||
case 'trading':
|
||||
return {
|
||||
label: 'Trading',
|
||||
icon: '📈',
|
||||
className: 'text-yellow-400 bg-yellow-500/10',
|
||||
};
|
||||
case 'security':
|
||||
return {
|
||||
label: 'Security',
|
||||
icon: '🔒',
|
||||
className: 'text-red-400 bg-red-500/10',
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user