added cors support and started chat client
This commit is contained in:
1579
package-lock.json
generated
1579
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
packages/chat-client/index.html
Normal file
12
packages/chat-client/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NexusAI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
packages/chat-client/package.json
Normal file
19
packages/chat-client/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@nexusai/chat-client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"uuid": "^13.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
packages/chat-client/src/App.jsx
Normal file
74
packages/chat-client/src/App.jsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import SessionList from './components/SessionList';
|
||||||
|
import ChatWindow from './components/ChatWindow';
|
||||||
|
import InfoPanel from './components/InfoPanel';
|
||||||
|
import { useSession } from './hooks/useSession';
|
||||||
|
import { useChat } from './hooks/useChat';
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = 'companion:latest';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [leftOpen, setLeftOpen] = useState(true);
|
||||||
|
const [rightOpen, setRightOpen] = useState(true);
|
||||||
|
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL);
|
||||||
|
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSession,
|
||||||
|
messages,
|
||||||
|
loadingHistory,
|
||||||
|
selectSession,
|
||||||
|
createSession,
|
||||||
|
refreshSessions,
|
||||||
|
appendMessage,
|
||||||
|
updateLastMessage,
|
||||||
|
} = useSession();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sendMessage,
|
||||||
|
cancelStream,
|
||||||
|
streaming,
|
||||||
|
lastTokenCount,
|
||||||
|
lastModel,
|
||||||
|
} = useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions });
|
||||||
|
|
||||||
|
function handleSendMessage(text) {
|
||||||
|
sendMessage(text, selectedModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
height: '100vh',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<SessionList
|
||||||
|
sessions={sessions}
|
||||||
|
activeSession={activeSession}
|
||||||
|
onSelectSession={selectSession}
|
||||||
|
onNewChat={createSession}
|
||||||
|
isOpen={leftOpen}
|
||||||
|
onToggle={() => setLeftOpen(o => !o)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChatWindow
|
||||||
|
messages={messages}
|
||||||
|
loadingHistory={loadingHistory}
|
||||||
|
streaming={streaming}
|
||||||
|
activeSession={activeSession}
|
||||||
|
onSendMessage={handleSendMessage}
|
||||||
|
onCancel={cancelStream}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<InfoPanel
|
||||||
|
isOpen={rightOpen}
|
||||||
|
onToggle={() => setRightOpen(o => !o)}
|
||||||
|
activeSession={activeSession}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
lastModel={lastModel}
|
||||||
|
lastTokenCount={lastTokenCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
packages/chat-client/src/api/orchestration.js
Normal file
80
packages/chat-client/src/api/orchestration.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_ORCHESTRATION_URL ?? '';
|
||||||
|
|
||||||
|
// ── Sessions ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function fetchSessions(limit = 20, offset = 0) {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions?limit=${limit}&offset=${offset}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch sessions: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSessionHistory(sessionId, limit = 50, offset = 0) {
|
||||||
|
const res = await fetch(`${BASE_URL}/sessions/${sessionId}/history?limit=${limit}&offset=${offset}`);
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch history: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function sendMessage(sessionId, message, model) {
|
||||||
|
const res = await fetch(`${BASE_URL}/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, message, model }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Chat request failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// onChunk(text) called for each token
|
||||||
|
// onDone({ model, tokenCount }) called when stream closes
|
||||||
|
// returns an abort function — call it to cancel mid-stream
|
||||||
|
export function streamMessage(sessionId, message, model, { onChunk, onDone, onError }) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BASE_URL}/chat/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, message, model }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`Stream request failed: ${res.status}`);
|
||||||
|
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
// Append to buffer and split on double newline (SSE event delimiter)
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const events = buffer.split('\n\n');
|
||||||
|
buffer = events.pop(); // last item may be incomplete — keep in buffer
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const line = event.trim();
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
|
||||||
|
const raw = line.slice(6);
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (data.text) onChunk(data.text);
|
||||||
|
if (data.done) onDone({ model, tokenCount: data.tokenCount ?? 0 });
|
||||||
|
if (data.error) onError(new Error(data.error));
|
||||||
|
} catch {
|
||||||
|
// malformed JSON — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') onError(err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
packages/chat-client/src/hooks/useChat.js
Normal file
90
packages/chat-client/src/hooks/useChat.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { streamMessage } from '../api/orchestration';
|
||||||
|
|
||||||
|
export function useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }) {
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [lastTokenCount, setLastTokenCount] = useState(0);
|
||||||
|
const [lastModel, setLastModel] = useState(null);
|
||||||
|
const cancelRef = useRef(null);
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async (text, model) => {
|
||||||
|
if (!activeSession || !text.trim() || streaming) return;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// 1. Append user bubble immediately
|
||||||
|
appendMessage({
|
||||||
|
id: `user-${Date.now()}`,
|
||||||
|
role: 'user',
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Append empty assistant bubble — will be filled by stream
|
||||||
|
appendMessage({
|
||||||
|
id: `assistant-${Date.now()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
text: '',
|
||||||
|
streaming: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setStreaming(true);
|
||||||
|
|
||||||
|
// 3. Open stream
|
||||||
|
cancelRef.current = streamMessage(
|
||||||
|
activeSession.external_id,
|
||||||
|
text,
|
||||||
|
model,
|
||||||
|
{
|
||||||
|
onChunk: (token) => {
|
||||||
|
updateLastMessage(msg => ({
|
||||||
|
...msg,
|
||||||
|
text: msg.text + token,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
onDone: ({ model: resolvedModel, tokenCount }) => {
|
||||||
|
// Mark bubble as complete
|
||||||
|
updateLastMessage(msg => ({ ...msg, streaming: false }));
|
||||||
|
setLastTokenCount(tokenCount);
|
||||||
|
setLastModel(resolvedModel);
|
||||||
|
setStreaming(false);
|
||||||
|
cancelRef.current = null;
|
||||||
|
|
||||||
|
// Refresh session list so new sessions appear in sidebar
|
||||||
|
refreshSessions();
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (err) => {
|
||||||
|
updateLastMessage(msg => ({
|
||||||
|
...msg,
|
||||||
|
text: msg.text || 'Something went wrong.',
|
||||||
|
streaming: false,
|
||||||
|
error: true,
|
||||||
|
}));
|
||||||
|
setError(err.message);
|
||||||
|
setStreaming(false);
|
||||||
|
cancelRef.current = null;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [activeSession, streaming, appendMessage, updateLastMessage, refreshSessions]);
|
||||||
|
|
||||||
|
const cancelStream = useCallback(() => {
|
||||||
|
if (cancelRef.current) {
|
||||||
|
cancelRef.current();
|
||||||
|
cancelRef.current = null;
|
||||||
|
updateLastMessage(msg => ({ ...msg, streaming: false }));
|
||||||
|
setStreaming(false);
|
||||||
|
}
|
||||||
|
}, [updateLastMessage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendMessage,
|
||||||
|
cancelStream,
|
||||||
|
streaming,
|
||||||
|
error,
|
||||||
|
lastTokenCount,
|
||||||
|
lastModel,
|
||||||
|
};
|
||||||
|
}
|
||||||
97
packages/chat-client/src/hooks/useSession.js
Normal file
97
packages/chat-client/src/hooks/useSession.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { fetchSessions, fetchSessionHistory } from '../api/orchestration';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const [sessions, setSessions] = useState([]);
|
||||||
|
const [activeSession, setActiveSession] = useState(null);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Load session list on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
const data = await fetchSessions();
|
||||||
|
setSessions(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to an existing session and load its history
|
||||||
|
const selectSession = useCallback(async (session) => {
|
||||||
|
setActiveSession(session);
|
||||||
|
setMessages([]);
|
||||||
|
setLoadingHistory(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetchSessionHistory(session.external_id);
|
||||||
|
// History comes back newest-first — reverse for display
|
||||||
|
const history = data.episodes.reverse().map(ep => ([
|
||||||
|
{ id: `${ep.id}-user`, role: 'user', text: ep.user_message },
|
||||||
|
{ id: `${ep.id}-ai`, role: 'assistant', text: ep.ai_response },
|
||||||
|
])).flat();
|
||||||
|
|
||||||
|
setMessages(history);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Create a new session with a generated UUID — no backend call needed yet,
|
||||||
|
// orchestration auto-creates the session on the first message
|
||||||
|
const createSession = useCallback(() => {
|
||||||
|
const newSession = {
|
||||||
|
external_id: uuidv4(),
|
||||||
|
metadata: null,
|
||||||
|
isNew: true, // flag so SessionList can style it differently
|
||||||
|
};
|
||||||
|
setSessions(prev => [newSession, ...prev]);
|
||||||
|
setActiveSession(newSession);
|
||||||
|
setMessages([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Called by useChat after a message completes — keeps session list fresh
|
||||||
|
const refreshSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchSessions();
|
||||||
|
setSessions(data);
|
||||||
|
} catch {
|
||||||
|
// non-critical — sidebar just won't update
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Append a message to the current thread (used by useChat)
|
||||||
|
const appendMessage = useCallback((message) => {
|
||||||
|
setMessages(prev => [...prev, message]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update the last message in the thread (used by useChat during streaming)
|
||||||
|
const updateLastMessage = useCallback((updater) => {
|
||||||
|
setMessages(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[updated.length - 1] = updater(updated[updated.length - 1]);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
activeSession,
|
||||||
|
messages,
|
||||||
|
loadingHistory,
|
||||||
|
error,
|
||||||
|
selectSession,
|
||||||
|
createSession,
|
||||||
|
refreshSessions,
|
||||||
|
appendMessage,
|
||||||
|
updateLastMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
packages/chat-client/src/index.css
Normal file
26
packages/chat-client/src/index.css
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-base: #0f1117;
|
||||||
|
--bg-surface: #1a1d27;
|
||||||
|
--bg-elevated: #222536;
|
||||||
|
--border: #2e3150;
|
||||||
|
--accent: #6c63ff;
|
||||||
|
--accent-hover: #574fd6;
|
||||||
|
--text-primary: #e8e8f0;
|
||||||
|
--text-secondary: #8b8fa8;
|
||||||
|
--text-muted: #555870;
|
||||||
|
--bubble-user: #6c63ff;
|
||||||
|
--bubble-ai: #222536;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--panel-width: 260px;
|
||||||
|
--header-height: 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
10
packages/chat-client/src/main.jsx
Normal file
10
packages/chat-client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
13
packages/chat-client/vite.config.js
Normal file
13
packages/chat-client/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/chat': 'http://192.168.0.205:4000',
|
||||||
|
'/sessions': 'http://192.168.0.205:4000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nexusai/shared": "^1.0.0",
|
"@nexusai/shared": "^1.0.0",
|
||||||
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.4.0",
|
"dotenv": "^17.4.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"node-fetch": "^2.7.0"
|
"node-fetch": "^2.7.0"
|
||||||
|
|||||||
@@ -3,12 +3,22 @@ const express = require('express');
|
|||||||
const {getEnv} = require('@nexusai/shared');
|
const {getEnv} = require('@nexusai/shared');
|
||||||
const chatRouter = require('./routes/chat');
|
const chatRouter = require('./routes/chat');
|
||||||
const sessionsRouter = require('./routes/sessions');
|
const sessionsRouter = require('./routes/sessions');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const PORT = getEnv('PORT', '4000'); // Default to 4000 if PORT is not set
|
const PORT = getEnv('PORT', '4000'); // Default to 4000 if PORT is not set
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: [
|
||||||
|
getEnv('CORS_ORIGIN', 'http://localhost:5173'),
|
||||||
|
'http://localhost:5173',
|
||||||
|
],
|
||||||
|
methods: ['GET', 'POST', 'DELETE'],
|
||||||
|
allowedHeaders: ['Content-Type'],
|
||||||
|
}))
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
Reference in New Issue
Block a user