Codeki / frontend /src /components /ChatPanel.tsx
Mayank2027's picture
Create frontend/src/components/ChatPanel.tsx
05736ce verified
Raw
History Blame Contribute Delete
9.35 kB
import React, { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import { FaUser, FaRobot, FaSpinner } from 'react-icons/fa';
import axios from 'axios';
interface Message {
role: 'user' | 'assistant';
content: string;
}
const ChatPanel: React.FC<{ projectPath: string; model: string }> = ({ projectPath, model }) => {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [mentionQuery, setMentionQuery] = useState('');
const [mentionItems, setMentionItems] = useState<any[]>([]);
const [showMentions, setShowMentions] = useState(false);
const [contextFiles, setContextFiles] = useState<string[]>([]);
const chatEnd = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const send = async () => {
if (!input.trim() || loading) return;
const userMsg = input;
setInput('');
setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
setLoading(true);
const assistantMsgRef = { current: '' };
setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, { role: 'user', content: userMsg }],
project_path: projectPath,
model: model,
context_files: contextFiles,
}),
});
if (!response.ok) throw new Error(`Server responded with ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const { token } = JSON.parse(data);
if (token.startsWith('ERROR:')) {
assistantMsgRef.current = `❌ ${token.substring(7)}`;
setMessages(prev => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: assistantMsgRef.current };
return copy;
});
return;
}
assistantMsgRef.current += token;
setMessages(prev => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: assistantMsgRef.current };
return copy;
});
} catch (e) {
assistantMsgRef.current = `Parse error: ${data}`;
setMessages(prev => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: assistantMsgRef.current };
return copy;
});
return;
}
}
}
}
} catch (err: any) {
setMessages(prev => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: `❌ ${err.message}` };
return copy;
});
} finally {
setLoading(false);
}
};
useEffect(() => { chatEnd.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setInput(value);
const atIndex = value.lastIndexOf('@');
if (atIndex !== -1 && (atIndex === 0 || value[atIndex - 1] === ' ')) {
const query = value.slice(atIndex + 1).toLowerCase();
setMentionQuery(query);
if ('git'.startsWith(query)) {
setMentionItems([{ name: 'git (current diff)', type: 'special', path: '__git__' }]);
setShowMentions(true);
return;
}
axios.get('/api/files', { params: { project_path: projectPath } })
.then(res => {
const flatten = (nodes: any[]): any[] => {
let result: any[] = [];
for (const node of nodes) {
if (node.name.toLowerCase().includes(query))
result.push(node);
if (node.children) result = result.concat(flatten(node.children));
}
return result;
};
const matches = flatten(res.data).slice(0, 5);
setMentionItems(matches);
setShowMentions(matches.length > 0);
})
.catch(() => setShowMentions(false));
} else {
setShowMentions(false);
}
};
const insertMention = (item: any) => {
const atIndex = input.lastIndexOf('@');
const newInput = input.slice(0, atIndex) + item.name + ' ';
setInput(newInput);
setShowMentions(false);
if (item.path === '__git__') {
if (!contextFiles.includes('__git__')) {
setContextFiles(prev => [...prev, '__git__']);
}
} else if (!contextFiles.includes(item.path)) {
setContextFiles(prev => [...prev, item.path]);
}
inputRef.current?.focus();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
{messages.map((m, i) => (
<div key={i} style={{ marginBottom: 16, display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
<div style={{
maxWidth: '80%',
background: m.role === 'user' ? 'var(--accent)' : 'var(--bg-tertiary)',
color: m.role === 'user' ? 'white' : 'var(--text-primary)',
borderRadius: 12,
borderBottomRightRadius: m.role === 'user' ? 4 : 12,
borderBottomLeftRadius: m.role === 'user' ? 12 : 4,
padding: 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
}}>
<div style={{ fontSize: 11, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 4, color: m.role === 'user' ? 'rgba(255,255,255,0.8)' : 'var(--text-secondary)' }}>
{m.role === 'user' ? <FaUser size={10} /> : <FaRobot size={10} />}
{m.role === 'user' ? 'You' : 'Codeki'}
</div>
<ReactMarkdown>{m.content}</ReactMarkdown>
</div>
</div>
))}
{loading && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', padding: 8 }}>
<FaSpinner className="spin" /> Codeki is thinking...
</div>
)}
<div ref={chatEnd} />
</div>
<div style={{ borderTop: '1px solid var(--border)', padding: 12, background: 'var(--bg-secondary)', position: 'relative' }}>
{showMentions && (
<div style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
background: 'var(--bg-tertiary)',
border: '1px solid var(--border)',
borderRadius: 4,
maxHeight: 150,
overflowY: 'auto',
marginBottom: 4,
zIndex: 10,
}}>
{mentionItems.map(item => (
<div
key={item.path}
onClick={() => insertMention(item)}
style={{ padding: '4px 8px', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6 }}
>
{item.type === 'folder' ? 'πŸ“' : item.type === 'special' ? 'πŸ”—' : 'πŸ“„'} {item.name}
</div>
))}
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<input
ref={inputRef}
style={{ flex: 1 }}
value={input}
onChange={handleInputChange}
onKeyDown={e => e.key === 'Enter' && send()}
placeholder="Ask anything... Type @ to reference a file or @git"
disabled={loading}
/>
<button
onClick={send}
disabled={loading}
style={{
background: loading ? 'var(--border)' : 'var(--accent)',
color: 'white',
padding: '0 20px',
borderRadius: 6,
fontWeight: 600,
transition: 'background 0.2s'
}}
>
{loading ? '...' : 'Send'}
</button>
</div>
{contextFiles.length > 0 && (
<div style={{ marginTop: 6, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{contextFiles.map(f => (
<span key={f} style={{ background: 'var(--accent)', color: 'white', padding: '2px 8px', borderRadius: 10, fontSize: 11 }}>
πŸ“Ž {f === '__git__' ? 'Git Diff' : f.split('/').pop()}
<span style={{ marginLeft: 4, cursor: 'pointer' }} onClick={() => setContextFiles(prev => prev.filter(x => x !== f))}>Γ—</span>
</span>
))}
</div>
)}
</div>
</div>
);
};
export default ChatPanel;