mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
* feat: add initial project structure and documentation - Introduced the GNU General Public License (GPL) v3 in LICENSE file. - Added MCP configuration in .claude/mcp.json for server integration. - Created architecture documentation in docs/ARCHITECTURE.md outlining the platform's design and components. - Defined MVP specifications in docs/MVP.md for the Telegram-based Agent Assistant. - Established API reference for team management in docs/teams-api-reference.md. - Set up basic HTML structure in public/index.html and added logo image in public/logo.png. * feat: add initial project documentation and HTML structure - Introduced CODE_OF_CONDUCT.md to establish community guidelines and standards for behavior. - Created CONTRIBUTING.md to outline contribution process, development setup, and project conventions. - Added SECURITY.md to define the security policy, supported versions, and reporting procedures for vulnerabilities. - Established basic HTML structure in index.html for the application interface. * chore: remove hello-python skill files - Deleted skill.json and skill.py files for the Hello Python example runtime skill, as they are no longer needed in the project. * feat: port tinyhuman agent runtime from ZeroClaw into Tauri backend Port daemon supervisor, health registry, security (policy, secrets, audit, pairing), agent traits, and config modules from ZeroClaw (MIT) into a new tinyhuman/ module under src-tauri/src/. The daemon auto-starts on desktop and shuts down gracefully on app exit via CancellationToken. - health: global HealthRegistry with component tracking and JSON snapshots - security/policy: SecurityPolicy with command validation, risk levels, rate limiting - security/secrets: ChaCha20-Poly1305 SecretStore with legacy XOR migration - security/audit: AuditLogger with JSON-line events and log rotation - security/pairing: PairingGuard with brute-force protection and SHA-256 hashing - security/traits: Sandbox trait + NoopSandbox - config: minimal DaemonConfig with autonomy, reliability, secrets, audit sub-configs - daemon: supervisor with health state writer emitting Tauri events - agent/traits: Provider, Tool, Memory, Observer, RuntimeAdapter traits + Noop impls - commands/tinyhuman: Tauri commands for health, security policy, encrypt/decrypt - 185 inline unit tests across all modules - README updated with custom inference/tunneling/memory positioning Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: update README to reflect AlphaHuman Mk1 branding and enhanced description - Changed project title to "AlphaHuman Mk1" for clarity. - Revised project description to emphasize user-friendly AI capabilities and the use of the Neocortex Mk1 model. - Removed outdated sections on custom inference, tunneling, and memory, streamlining the content for better readability. * update readme * Port zeroclaw runtime into tinyhuman * Replace CLI mentions with UI language * Split gateway module into smaller units * Split channels and config schema modules * Fix tinyhuman build, tests, and tunnel integration * feat(tinyhuman): add missing modules and ui-friendly services * refactor: rename tinyhuman to alphahuman * chore: remove bottom text from Welcome component * feat(settings): add tauri command console * feat(daemon): enhance daemon mode handling and integrate rustls with ring feature * feat(settings): implement comprehensive configuration management in TauriCommandsPanel * refactor(TauriCommandsPanel): streamline error handling and enhance async function usage * feat(settings): add skill management functionality to TauriCommandsPanel * style(TauriCommandsPanel): update input styles for improved readability and user experience * feat(settings): add Skills and Agent Chat panels with navigation and integration management * feat(settings): implement browser access management in SkillsPanel and enhance AgentChatPanel with local storage functionality * Implement inference API integration in Conversations component - Added inference API service to handle chat completions and model management. - Updated Conversations component to fetch available models on mount and allow model selection. - Enhanced message sending functionality to utilize the inference API for generating responses. - Introduced state management for loading models and handling errors during API calls. - Refactored optimistic message handling and send status management for improved user experience. --------- Co-authored-by: Steven Enamakel <enamakel@vezures.xyz> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
890 lines
37 KiB
TypeScript
890 lines
37 KiB
TypeScript
import {
|
|
type PointerEvent as ReactPointerEvent,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import Markdown from 'react-markdown';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
|
|
import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi';
|
|
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
|
import {
|
|
addInferenceResponse,
|
|
addOptimisticMessage,
|
|
clearCreateStatus,
|
|
clearDeleteStatus,
|
|
clearPurgeStatus,
|
|
clearSelectedThread,
|
|
createThread,
|
|
deleteThread,
|
|
fetchSuggestedQuestions,
|
|
fetchThreadMessages,
|
|
fetchThreads,
|
|
purgeThreads,
|
|
removeOptimisticMessages,
|
|
setLastViewed,
|
|
setPanelWidth,
|
|
setSelectedThread,
|
|
} from '../store/threadSlice';
|
|
|
|
const MIN_PANEL_WIDTH = 200;
|
|
const MAX_PANEL_WIDTH = 480;
|
|
|
|
function formatRelativeTime(dateStr: string): string {
|
|
const now = Date.now();
|
|
const then = new Date(dateStr).getTime();
|
|
const diffMs = now - then;
|
|
if (diffMs < 60_000) return 'just now';
|
|
const mins = Math.floor(diffMs / 60_000);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
const Conversations = () => {
|
|
const dispatch = useAppDispatch();
|
|
const navigate = useNavigate();
|
|
const { threadId: urlThreadId } = useParams<{ threadId?: string }>();
|
|
const {
|
|
threads,
|
|
isLoading,
|
|
error,
|
|
selectedThreadId,
|
|
messages,
|
|
isLoadingMessages,
|
|
messagesError,
|
|
createStatus,
|
|
deleteStatus,
|
|
purgeStatus,
|
|
panelWidth,
|
|
lastViewedAt,
|
|
suggestedQuestions,
|
|
isLoadingSuggestions,
|
|
} = useAppSelector(state => state.thread);
|
|
|
|
const [showPurgeConfirm, setShowPurgeConfirm] = useState(false);
|
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
|
|
|
// Inference model state
|
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
|
const [selectedModel, setSelectedModel] = useState('neocortex-mk1');
|
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
|
const [isSending, setIsSending] = useState(false);
|
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
const isDragging = useRef(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const lastPanelWidthRef = useRef(panelWidth);
|
|
|
|
// Filtered threads based on search query (#13)
|
|
const filteredThreads = useMemo(() => {
|
|
if (!searchQuery.trim()) return threads;
|
|
const q = searchQuery.toLowerCase();
|
|
return threads.filter(t => (t.title || 'Untitled Thread').toLowerCase().includes(q));
|
|
}, [threads, searchQuery]);
|
|
|
|
// Unread: thread has messages since last view (#15)
|
|
const isThreadUnread = useCallback(
|
|
(thread: { id: string; lastMessageAt?: string | null; createdAt: string }) => {
|
|
const viewed = lastViewedAt[thread.id];
|
|
const lastMsg = new Date(thread.lastMessageAt || thread.createdAt).getTime();
|
|
return viewed == null || lastMsg > viewed;
|
|
},
|
|
[lastViewedAt]
|
|
);
|
|
|
|
// Mobile: detect small screens for responsive layout (#12)
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
useEffect(() => {
|
|
const mq = window.matchMedia('(max-width: 767px)');
|
|
setIsMobile(mq.matches);
|
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
|
mq.addEventListener('change', handler);
|
|
return () => mq.removeEventListener('change', handler);
|
|
}, []);
|
|
|
|
const handleResizePointerDown = useCallback(
|
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
isDragging.current = true;
|
|
const startX = e.clientX;
|
|
const startWidth = panelWidth;
|
|
|
|
const onPointerMove = (ev: globalThis.PointerEvent) => {
|
|
const delta = ev.clientX - startX;
|
|
const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta));
|
|
lastPanelWidthRef.current = newWidth;
|
|
dispatch(setPanelWidth(newWidth));
|
|
};
|
|
|
|
const onPointerUp = () => {
|
|
isDragging.current = false;
|
|
document.removeEventListener('pointermove', onPointerMove);
|
|
document.removeEventListener('pointerup', onPointerUp);
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
};
|
|
|
|
document.addEventListener('pointermove', onPointerMove);
|
|
document.addEventListener('pointerup', onPointerUp);
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
},
|
|
[panelWidth, dispatch]
|
|
);
|
|
|
|
// Fetch available inference models on mount
|
|
useEffect(() => {
|
|
setIsLoadingModels(true);
|
|
inferenceApi
|
|
.listModels()
|
|
.then(data => {
|
|
if (data.data.length > 0) {
|
|
setAvailableModels(data.data);
|
|
setSelectedModel(data.data[0].id);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Keep default model on failure
|
|
})
|
|
.finally(() => setIsLoadingModels(false));
|
|
}, []);
|
|
|
|
// Fetch threads on mount
|
|
useEffect(() => {
|
|
dispatch(fetchThreads());
|
|
}, [dispatch]);
|
|
|
|
// Sync URL → Redux: when URL has a threadId param, select that thread
|
|
useEffect(() => {
|
|
if (urlThreadId && urlThreadId !== selectedThreadId) {
|
|
dispatch(setSelectedThread(urlThreadId));
|
|
} else if (!urlThreadId && selectedThreadId) {
|
|
dispatch(clearSelectedThread());
|
|
}
|
|
}, [urlThreadId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Mark thread as viewed when selected (#15) — stored in Redux (persisted via redux-persist)
|
|
useEffect(() => {
|
|
if (selectedThreadId) dispatch(setLastViewed(selectedThreadId));
|
|
}, [selectedThreadId, dispatch]);
|
|
|
|
// Fetch messages when a thread is selected
|
|
useEffect(() => {
|
|
if (selectedThreadId) {
|
|
dispatch(fetchThreadMessages(selectedThreadId));
|
|
}
|
|
}, [dispatch, selectedThreadId]);
|
|
|
|
// Fetch suggested questions when thread is empty (beginning of new thread)
|
|
useEffect(() => {
|
|
if (selectedThreadId && messages.length === 0) {
|
|
dispatch(fetchSuggestedQuestions(selectedThreadId));
|
|
}
|
|
}, [selectedThreadId, messages.length, dispatch]);
|
|
|
|
// Auto-scroll to bottom when messages load
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [messages]);
|
|
|
|
// Clear transient status flags after they settle
|
|
useEffect(() => {
|
|
if (createStatus === 'success' || createStatus === 'error') {
|
|
dispatch(clearCreateStatus());
|
|
}
|
|
}, [createStatus, dispatch]);
|
|
|
|
useEffect(() => {
|
|
if (deleteStatus === 'success' || deleteStatus === 'error') {
|
|
dispatch(clearDeleteStatus());
|
|
}
|
|
}, [deleteStatus, dispatch]);
|
|
|
|
useEffect(() => {
|
|
if (purgeStatus === 'success' || purgeStatus === 'error') {
|
|
dispatch(clearPurgeStatus());
|
|
}
|
|
}, [purgeStatus, dispatch]);
|
|
|
|
// Clear send error when user starts typing again
|
|
useEffect(() => {
|
|
if (sendError && inputValue.length > 0) {
|
|
setSendError(null);
|
|
}
|
|
}, [inputValue, sendError]);
|
|
|
|
const handleSelectThread = (threadId: string) => {
|
|
if (threadId === selectedThreadId) return;
|
|
navigate(`/conversations/${threadId}`, { replace: true });
|
|
};
|
|
|
|
const handleNewThread = async () => {
|
|
const result = await dispatch(createThread(undefined));
|
|
if (createThread.fulfilled.match(result)) {
|
|
navigate(`/conversations/${result.payload.id}`, { replace: true });
|
|
}
|
|
};
|
|
|
|
const handleDeleteThread = async (threadId: string) => {
|
|
const result = await dispatch(deleteThread(threadId));
|
|
setConfirmDeleteId(null);
|
|
if (deleteThread.fulfilled.match(result) && threadId === selectedThreadId) {
|
|
navigate('/conversations', { replace: true });
|
|
}
|
|
};
|
|
|
|
const handlePurge = async () => {
|
|
const result = await dispatch(purgeThreads());
|
|
if (purgeThreads.fulfilled.match(result)) {
|
|
setShowPurgeConfirm(false);
|
|
navigate('/conversations', { replace: true });
|
|
}
|
|
};
|
|
|
|
const handleSendMessage = async (text?: string) => {
|
|
const trimmed = text ?? inputValue.trim();
|
|
if (!trimmed || !selectedThreadId || isSending) return;
|
|
|
|
// Snapshot history before the optimistic update (exclude stale optimistic msgs)
|
|
const historySnapshot = messages.filter(m => !m.id.startsWith('optimistic-'));
|
|
|
|
dispatch(addOptimisticMessage({ content: trimmed }));
|
|
setInputValue('');
|
|
setSendError(null);
|
|
setIsSending(true);
|
|
|
|
try {
|
|
const chatMessages = [
|
|
...historySnapshot.map(m => ({
|
|
role: (m.sender === 'user' ? 'user' : 'assistant') as 'user' | 'assistant',
|
|
content: m.content,
|
|
})),
|
|
{ role: 'user' as const, content: trimmed },
|
|
];
|
|
|
|
const response = await inferenceApi.createChatCompletion({
|
|
model: selectedModel,
|
|
messages: chatMessages,
|
|
});
|
|
|
|
const content = response.choices[0]?.message?.content ?? '';
|
|
dispatch(addInferenceResponse({ content }));
|
|
} catch (err) {
|
|
dispatch(removeOptimisticMessages());
|
|
const msg =
|
|
err && typeof err === 'object' && 'error' in err
|
|
? String((err as { error: unknown }).error)
|
|
: 'Failed to get response';
|
|
setSendError(msg);
|
|
} finally {
|
|
setIsSending(false);
|
|
}
|
|
};
|
|
|
|
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSendMessage();
|
|
}
|
|
};
|
|
|
|
// Copy message to clipboard (#10)
|
|
const handleCopyMessage = async (messageId: string, content: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(content);
|
|
setCopiedMessageId(messageId);
|
|
setTimeout(() => setCopiedMessageId(null), 1500);
|
|
} catch {
|
|
// Clipboard API not available — silently fail
|
|
}
|
|
};
|
|
|
|
// Mobile: back to thread list
|
|
const handleMobileBack = () => {
|
|
navigate('/conversations', { replace: true });
|
|
};
|
|
|
|
const selectedThread = threads.find(t => t.id === selectedThreadId);
|
|
|
|
// Mobile layout: show only one panel at a time (#12)
|
|
const showThreadList = !isMobile || !selectedThreadId;
|
|
const showMessages = !isMobile || !!selectedThreadId;
|
|
|
|
return (
|
|
<div className="h-full relative z-10 flex overflow-hidden">
|
|
{/* Left Panel: Thread List */}
|
|
{showThreadList && (
|
|
<div
|
|
className="flex-shrink-0 flex flex-col"
|
|
style={isMobile ? { width: '100%' } : { width: panelWidth }}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10">
|
|
<h2 className="text-sm font-semibold">Conversations</h2>
|
|
<button
|
|
onClick={handleNewThread}
|
|
disabled={createStatus === 'loading'}
|
|
className="p-1.5 rounded-lg hover:bg-white/10 transition-colors text-stone-400 hover:text-stone-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
title="New Thread">
|
|
{createStatus === 'loading' ? (
|
|
<svg className="w-4.5 h-4.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4.5 h-4.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search bar (#13) */}
|
|
{threads.length > 0 && (
|
|
<div className="px-3 py-2 border-b border-white/10">
|
|
<div className="relative">
|
|
<svg
|
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-stone-500"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
placeholder="Search threads..."
|
|
className="w-full bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-stone-500 hover:text-stone-300">
|
|
<svg className="w-3 h-3" 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>
|
|
)}
|
|
|
|
{/* Thread list */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{isLoading ? (
|
|
<div className="space-y-1 p-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-16 bg-white/5 rounded-xl animate-pulse" />
|
|
))}
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center py-16 px-4">
|
|
<svg
|
|
className="w-8 h-8 text-coral-500/70 mb-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm text-stone-400 mb-1">Failed to load conversations</p>
|
|
<p className="text-xs text-stone-600 mb-3 text-center">{error}</p>
|
|
<button
|
|
onClick={() => dispatch(fetchThreads())}
|
|
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
|
|
Retry
|
|
</button>
|
|
</div>
|
|
) : filteredThreads.length > 0 ? (
|
|
<div className="py-1">
|
|
{filteredThreads.map(thread => (
|
|
<div
|
|
key={thread.id}
|
|
className={`group relative transition-colors ${
|
|
thread.id === selectedThreadId ? 'bg-white/10' : 'hover:bg-white/[0.07]'
|
|
}`}>
|
|
{confirmDeleteId === thread.id ? (
|
|
<div className="flex items-center justify-between py-3 px-4">
|
|
<span className="text-xs text-stone-400 truncate">Delete this thread?</span>
|
|
<div className="flex gap-2 flex-shrink-0 ml-2">
|
|
<button
|
|
onClick={() => setConfirmDeleteId(null)}
|
|
className="text-xs text-stone-500 hover:text-stone-300 transition-colors">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteThread(thread.id)}
|
|
disabled={deleteStatus === 'loading'}
|
|
className="text-xs text-coral-500 hover:text-coral-400 transition-colors disabled:opacity-50">
|
|
{deleteStatus === 'loading' ? 'Deleting...' : 'Delete'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<button
|
|
onClick={() => handleSelectThread(thread.id)}
|
|
className="w-full text-left py-3 px-4 cursor-pointer">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span
|
|
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
|
|
thread.isActive ? 'bg-sage-500' : 'bg-stone-600'
|
|
}`}
|
|
/>
|
|
<span
|
|
className={`text-sm font-medium truncate ${
|
|
isThreadUnread(thread) ? 'font-semibold text-stone-200' : ''
|
|
}`}>
|
|
{thread.title || 'Untitled Thread'}
|
|
</span>
|
|
{isThreadUnread(thread) && (
|
|
<span
|
|
className="w-1.5 h-1.5 rounded-full bg-primary-500 flex-shrink-0"
|
|
title="Unread"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-between pl-3.5">
|
|
<span className="text-xs text-stone-500">
|
|
{thread.messageCount} message{thread.messageCount !== 1 ? 's' : ''}
|
|
</span>
|
|
<span className="text-xs text-stone-600">
|
|
{formatRelativeTime(thread.lastMessageAt || thread.createdAt)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
{/* Delete button — visible on hover */}
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
setConfirmDeleteId(thread.id);
|
|
}}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 text-stone-600 hover:text-coral-500 transition-all"
|
|
title="Delete thread">
|
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : threads.length > 0 && searchQuery ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center py-16 px-4">
|
|
<p className="text-sm text-stone-500">No matching threads</p>
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="text-xs text-primary-400 hover:text-primary-300 transition-colors mt-2">
|
|
Clear search
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex flex-col items-center justify-center py-16 px-4">
|
|
<svg
|
|
className="w-10 h-10 text-stone-600 mb-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm text-stone-500 mb-3">No conversations yet</p>
|
|
<button
|
|
onClick={handleNewThread}
|
|
disabled={createStatus === 'loading'}
|
|
className="text-xs text-primary-400 hover:text-primary-300 transition-colors disabled:opacity-50">
|
|
Start a new conversation
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer: Delete All */}
|
|
{threads.length > 0 && (
|
|
<div className="px-4 py-3 border-t border-white/10">
|
|
{showPurgeConfirm ? (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-stone-400">Delete all threads?</span>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setShowPurgeConfirm(false)}
|
|
className="text-xs text-stone-500 hover:text-stone-300 transition-colors">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handlePurge}
|
|
disabled={purgeStatus === 'loading'}
|
|
className="text-xs text-coral-500 hover:text-coral-400 transition-colors disabled:opacity-50">
|
|
{purgeStatus === 'loading' ? 'Deleting...' : 'Confirm'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowPurgeConfirm(true)}
|
|
className="text-xs text-coral-500/70 hover:text-coral-500 transition-colors">
|
|
Delete All Threads
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Resize Handle — desktop only */}
|
|
{!isMobile && (
|
|
<div
|
|
onPointerDown={handleResizePointerDown}
|
|
className="w-1 flex-shrink-0 cursor-col-resize bg-white/10 hover:bg-primary-500/40 active:bg-primary-500/60 transition-colors"
|
|
/>
|
|
)}
|
|
|
|
{/* Right Panel: Messages */}
|
|
{showMessages && (
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
{selectedThread ? (
|
|
<>
|
|
{/* Thread header */}
|
|
<div className="flex items-center gap-3 px-5 py-3 border-b border-white/10">
|
|
{/* Mobile back button (#12) */}
|
|
{isMobile && (
|
|
<button
|
|
onClick={handleMobileBack}
|
|
className="p-1 rounded-lg hover:bg-white/10 text-stone-400 hover:text-stone-200 transition-colors flex-shrink-0 -ml-1">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M15 19l-7-7 7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-semibold truncate">
|
|
{selectedThread.title || 'Untitled Thread'}
|
|
</h3>
|
|
{selectedThread.isActive && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-sage-500/20 text-sage-500 flex-shrink-0">
|
|
Active
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-stone-500 mt-0.5">
|
|
Created {formatRelativeTime(selectedThread.createdAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
{isLoadingMessages ? (
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={`flex ${i % 2 === 0 ? 'justify-start' : 'justify-end'}`}>
|
|
<div
|
|
className={`h-12 rounded-2xl animate-pulse bg-white/5 ${
|
|
i % 2 === 0 ? 'w-2/3' : 'w-1/2'
|
|
}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : messagesError ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center h-full">
|
|
<svg
|
|
className="w-8 h-8 text-coral-500/70 mb-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm text-stone-400 mb-1">Failed to load messages</p>
|
|
<p className="text-xs text-stone-600 mb-3 text-center">{messagesError}</p>
|
|
<button
|
|
onClick={() =>
|
|
selectedThreadId && dispatch(fetchThreadMessages(selectedThreadId))
|
|
}
|
|
className="text-xs text-primary-400 hover:text-primary-300 transition-colors">
|
|
Retry
|
|
</button>
|
|
</div>
|
|
) : messages.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{messages.map(msg => (
|
|
<div
|
|
key={msg.id}
|
|
className={`group/msg flex ${msg.sender === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
<div className="relative max-w-[75%]">
|
|
<div
|
|
className={`rounded-2xl px-4 py-2.5 ${
|
|
msg.sender === 'user'
|
|
? 'bg-primary-600/20 rounded-br-md'
|
|
: 'bg-white/5 rounded-bl-md'
|
|
}`}>
|
|
{msg.sender === 'agent' ? (
|
|
<div className="text-sm prose prose-invert prose-sm max-w-none prose-p:my-1 prose-pre:my-2 prose-pre:bg-black/30 prose-pre:rounded-lg prose-code:text-primary-300 prose-code:text-xs prose-a:text-primary-400 prose-headings:text-sm prose-headings:font-semibold prose-ul:my-1 prose-ol:my-1 prose-li:my-0">
|
|
<Markdown>{msg.content}</Markdown>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm whitespace-pre-wrap break-words">
|
|
{msg.content}
|
|
</p>
|
|
)}
|
|
<p
|
|
className={`text-[10px] mt-1 ${
|
|
msg.sender === 'user' ? 'text-primary-400/50' : 'text-stone-600'
|
|
}`}>
|
|
{formatRelativeTime(msg.createdAt)}
|
|
</p>
|
|
</div>
|
|
{/* Copy button (#10) */}
|
|
<button
|
|
onClick={() => handleCopyMessage(msg.id, msg.content)}
|
|
className={`absolute -top-1 ${msg.sender === 'user' ? '-left-8' : '-right-8'} p-1 rounded-md opacity-0 group-hover/msg:opacity-100 hover:bg-white/10 text-stone-600 hover:text-stone-300 transition-all`}
|
|
title="Copy message">
|
|
{copiedMessageId === msg.id ? (
|
|
<svg
|
|
className="w-3.5 h-3.5 text-sage-500"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 13l4 4L19 7"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<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="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{/* Typing indicator (#14) */}
|
|
{isSending && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white/5 rounded-2xl rounded-bl-md px-4 py-3">
|
|
<div className="flex items-center gap-1">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-stone-500 animate-bounce [animation-delay:0ms]" />
|
|
<span className="w-1.5 h-1.5 rounded-full bg-stone-500 animate-bounce [animation-delay:150ms]" />
|
|
<span className="w-1.5 h-1.5 rounded-full bg-stone-500 animate-bounce [animation-delay:300ms]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center h-full">
|
|
<p className="text-sm text-stone-600">No messages in this thread</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Suggested questions — only at start of new thread (no messages yet); horizontal scroll */}
|
|
{messages.length === 0 && suggestedQuestions.length > 0 && !isLoadingSuggestions && (
|
|
<div className="flex-shrink-0 px-4 py-3">
|
|
<div className="flex gap-2 overflow-x-auto scrollbar-hide">
|
|
{suggestedQuestions.map((s, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => handleSendMessage(s.text)}
|
|
disabled={isSending}
|
|
className="flex-shrink-0 px-3 py-1.5 rounded-lg text-[12px] whitespace-nowrap bg-white/5 text-stone-400 hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
|
{s.text}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Message Input */}
|
|
<div className="flex-shrink-0 border-t border-white/10 px-4 py-3">
|
|
{/* Model selector */}
|
|
<div className="flex items-center gap-2 mb-2">
|
|
{isLoadingModels ? (
|
|
<span className="text-xs text-stone-600">Loading models…</span>
|
|
) : (
|
|
<>
|
|
<span className="text-xs text-stone-500">Model</span>
|
|
<select
|
|
value={selectedModel}
|
|
onChange={e => setSelectedModel(e.target.value)}
|
|
disabled={isSending}
|
|
className="bg-white/5 border border-white/10 rounded-lg px-2 py-1 text-xs text-stone-300 focus:outline-none focus:ring-1 focus:ring-primary-500/50 disabled:opacity-50 cursor-pointer">
|
|
{availableModels.length > 0 ? (
|
|
availableModels.map(m => (
|
|
<option key={m.id} value={m.id} className="bg-stone-900">
|
|
{m.id}
|
|
</option>
|
|
))
|
|
) : (
|
|
<option value={selectedModel} className="bg-stone-900">
|
|
{selectedModel}
|
|
</option>
|
|
)}
|
|
</select>
|
|
</>
|
|
)}
|
|
</div>
|
|
{sendError && (
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-xs text-coral-500">{sendError}</p>
|
|
<button
|
|
onClick={() => setSendError(null)}
|
|
className="text-xs text-stone-500 hover:text-stone-300 transition-colors ml-2 flex-shrink-0">
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
value={inputValue}
|
|
onChange={e => setInputValue(e.target.value)}
|
|
onKeyDown={handleInputKeyDown}
|
|
placeholder="Type a message..."
|
|
rows={1}
|
|
className="flex-1 resize-none bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-sm placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all max-h-32"
|
|
/>
|
|
<button
|
|
onClick={() => handleSendMessage()}
|
|
disabled={!inputValue.trim() || isSending}
|
|
className="p-2.5 rounded-xl bg-primary-600 hover:bg-primary-500 disabled:opacity-40 disabled:cursor-not-allowed transition-colors flex-shrink-0">
|
|
{isSending ? (
|
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
className="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M5 12h14M12 5l7 7-7 7"
|
|
/>
|
|
</svg>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
/* Empty state — no thread selected */
|
|
<div className="flex-1 flex flex-col items-center justify-center">
|
|
<svg
|
|
className="w-12 h-12 text-stone-700 mb-3"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24">
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={1.5}
|
|
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
/>
|
|
</svg>
|
|
<p className="text-sm text-stone-600">Select a conversation</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Conversations;
|