Spaces:
Paused
Paused
| 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; |