added cors support and started chat client
This commit is contained in:
183
packages/chat-client/src/components/ChatWindow.jsx
Normal file
183
packages/chat-client/src/components/ChatWindow.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import MessageBubble from './MessageBubble';
|
||||
|
||||
export default function ChatWindow({ messages, loadingHistory, streaming, onSendMessage, onCancel, activeSession }) {
|
||||
const bottomRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [input, setInput] = React.useState('');
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
function handleSend() {
|
||||
const text = input.trim();
|
||||
if (!text || streaming) return;
|
||||
setInput('');
|
||||
onSendMessage(text);
|
||||
}
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--bg-base)',
|
||||
}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
height: 'var(--header-height)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 20px',
|
||||
background: 'var(--bg-surface)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{ color: 'var(--text-secondary)', fontSize: '13px' }}>
|
||||
{activeSession ? activeSession.external_id : 'No session selected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Message thread */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '20px 0',
|
||||
}}>
|
||||
{!activeSession && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
color: 'var(--text-muted)',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div style={{ fontSize: '32px', opacity: 0.4 }}>✦</div>
|
||||
<p style={{ fontSize: '14px' }}>Select a session or start a new chat</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingHistory && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '13px',
|
||||
}}>
|
||||
Loading history...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingHistory && messages.map(msg => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--border)',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--bg-surface)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-end',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!activeSession}
|
||||
placeholder={activeSession ? 'Message NexusAI...' : 'Select a session to start chatting'}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
resize: 'none',
|
||||
fontFamily: 'inherit',
|
||||
maxHeight: '120px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
onInput={e => {
|
||||
// Auto-grow textarea
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
|
||||
{streaming ? (
|
||||
<button onClick={onCancel} style={{
|
||||
background: 'var(--text-muted)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
}}>■</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!activeSession || !input.trim()}
|
||||
style={{
|
||||
background: activeSession && input.trim() ? 'var(--accent)' : 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
cursor: activeSession && input.trim() ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
color: activeSession && input.trim() ? 'white' : 'var(--text-muted)',
|
||||
fontSize: '16px',
|
||||
transition: 'background 0.15s',
|
||||
}}>↑</button>
|
||||
)}
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
textAlign: 'center',
|
||||
marginTop: '8px',
|
||||
}}>
|
||||
Enter to send · Shift+Enter for new line
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
packages/chat-client/src/components/InfoPanel.jsx
Normal file
189
packages/chat-client/src/components/InfoPanel.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
|
||||
const MODELS = [
|
||||
{ value: 'companion:latest', label: 'Companion' },
|
||||
{ value: 'mistral-nemo:latest', label: 'Mistral Nemo' },
|
||||
{ value: 'coder:latest', label: 'Coder' },
|
||||
{ value: 'qwen2.5-coder:14b', label: 'Qwen 2.5 Coder 14B' },
|
||||
];
|
||||
|
||||
export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel, lastTokenCount, selectedModel, onModelChange }) {
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: isOpen ? 'var(--panel-width)' : '56px',
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-surface)',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'width 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
height: 'var(--header-height)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isOpen ? 'space-between' : 'center',
|
||||
padding: isOpen ? '0 16px 0 12px' : '0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<button onClick={onToggle} style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '6px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{isOpen ? '▶' : '◀'}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<span style={{ fontSize: '13px', fontWeight: 500, color: 'var(--text-secondary)' }}>
|
||||
Session Info
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px' }}>
|
||||
|
||||
{/* Model selector */}
|
||||
<Section title="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={e => onModelChange(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{MODELS.map(m => (
|
||||
<option key={m.value} value={m.value}>{m.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Section>
|
||||
|
||||
{/* Session details */}
|
||||
<Section title="Session">
|
||||
{activeSession ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<InfoRow label="ID" value={activeSession.external_id} mono truncate />
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={activeSession.isNew ? 'Unsaved' : 'Active'}
|
||||
accent={activeSession.isNew}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-muted)' }}>No session selected</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Last response stats */}
|
||||
<Section title="Last Response">
|
||||
{lastModel ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<InfoRow label="Model" value={lastModel} />
|
||||
<InfoRow label="Tokens" value={lastTokenCount > 0 ? lastTokenCount.toLocaleString() : '—'} />
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: 'var(--text-muted)' }}>No response yet</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed — show icon indicators */}
|
||||
{!isOpen && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
paddingTop: '16px',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<IconHint title="Model">M</IconHint>
|
||||
<IconHint title="Session">S</IconHint>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Internal sub-components ──────────────────────────────────
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<p style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
{title}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, mono, truncate, accent }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text-muted)', flexShrink: 0 }}>{label}</span>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: accent ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
fontFamily: mono ? 'monospace' : 'inherit',
|
||||
overflow: truncate ? 'hidden' : 'visible',
|
||||
textOverflow: truncate ? 'ellipsis' : 'clip',
|
||||
whiteSpace: truncate ? 'nowrap' : 'normal',
|
||||
maxWidth: truncate ? '130px' : 'auto',
|
||||
textAlign: 'right',
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconHint({ title, children }) {
|
||||
return (
|
||||
<div title={title} style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'default',
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
packages/chat-client/src/components/MessageBubble.jsx
Normal file
66
packages/chat-client/src/components/MessageBubble.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function MessageBubble({ message }) {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
||||
marginBottom: '12px',
|
||||
padding: '0 16px',
|
||||
}}>
|
||||
{!isUser && (
|
||||
<div style={{
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--accent)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
marginRight: '8px',
|
||||
flexShrink: 0,
|
||||
alignSelf: 'flex-end',
|
||||
}}>N</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
maxWidth: '70%',
|
||||
padding: '10px 14px',
|
||||
borderRadius: isUser ? '18px 18px 4px 18px' : '18px 18px 18px 4px',
|
||||
background: isUser ? 'var(--bubble-user)' : 'var(--bubble-ai)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
border: isUser ? 'none' : '1px solid var(--border)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{message.text}
|
||||
{message.streaming && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '8px',
|
||||
height: '14px',
|
||||
background: 'var(--text-secondary)',
|
||||
marginLeft: '2px',
|
||||
borderRadius: '2px',
|
||||
animation: 'blink 1s step-end infinite',
|
||||
}} />
|
||||
)}
|
||||
{message.error && (
|
||||
<div style={{
|
||||
marginTop: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#ff6b6b',
|
||||
}}>
|
||||
⚠ Failed to complete response
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
packages/chat-client/src/components/SessionList.jsx
Normal file
161
packages/chat-client/src/components/SessionList.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function SessionList({ sessions, activeSession, onSelectSession, onNewChat, isOpen, onToggle }) {
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
const date = new Date(ts * 1000);
|
||||
const now = new Date();
|
||||
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return date.toLocaleDateString([], { weekday: 'long' });
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getPreview(session) {
|
||||
if (session.isNew) return 'New conversation';
|
||||
return session.external_id;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: isOpen ? 'var(--sidebar-width)' : '56px',
|
||||
flexShrink: 0,
|
||||
background: 'var(--bg-surface)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'width 0.2s ease',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
height: 'var(--header-height)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isOpen ? 'space-between' : 'center',
|
||||
padding: isOpen ? '0 12px 0 16px' : '0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{isOpen && (
|
||||
<span style={{ fontSize: '13px', fontWeight: 500, color: 'var(--text-secondary)' }}>
|
||||
Conversations
|
||||
</span>
|
||||
)}
|
||||
<button onClick={onToggle} style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: '6px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{isOpen ? '◀' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New chat button */}
|
||||
<div style={{ padding: isOpen ? '12px' : '12px 8px', flexShrink: 0 }}>
|
||||
<button onClick={onNewChat} style={{
|
||||
width: '100%',
|
||||
padding: isOpen ? '8px 12px' : '8px',
|
||||
background: 'var(--accent)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: isOpen ? 'flex-start' : 'center',
|
||||
gap: '8px',
|
||||
transition: 'background 0.15s',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--accent-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--accent)'}
|
||||
>
|
||||
<span style={{ fontSize: '18px', lineHeight: 1, flexShrink: 0 }}>+</span>
|
||||
{isOpen && <span>New Chat</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||
{isOpen && sessions.map(session => {
|
||||
const isActive = activeSession?.external_id === session.external_id;
|
||||
return (
|
||||
<button
|
||||
key={session.external_id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
background: isActive ? 'var(--bg-elevated)' : 'transparent',
|
||||
border: 'none',
|
||||
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '3px',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-elevated)'; }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; }}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}>
|
||||
{getPreview(session)}
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: 'var(--text-muted)', flexShrink: 0 }}>
|
||||
{formatDate(session.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
{session.isNew && (
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--accent)',
|
||||
fontStyle: 'italic',
|
||||
}}>Unsaved</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{isOpen && sessions.length === 0 && (
|
||||
<div style={{
|
||||
padding: '24px 16px',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '13px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user