Mayank2027 commited on
Commit
05736ce
Β·
verified Β·
1 Parent(s): 2e33a07

Create frontend/src/components/ChatPanel.tsx

Browse files
Files changed (1) hide show
  1. frontend/src/components/ChatPanel.tsx +251 -0
frontend/src/components/ChatPanel.tsx ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import { FaUser, FaRobot, FaSpinner } from 'react-icons/fa';
4
+ import axios from 'axios';
5
+
6
+ interface Message {
7
+ role: 'user' | 'assistant';
8
+ content: string;
9
+ }
10
+
11
+ const ChatPanel: React.FC<{ projectPath: string; model: string }> = ({ projectPath, model }) => {
12
+ const [messages, setMessages] = useState<Message[]>([]);
13
+ const [input, setInput] = useState('');
14
+ const [loading, setLoading] = useState(false);
15
+ const [mentionQuery, setMentionQuery] = useState('');
16
+ const [mentionItems, setMentionItems] = useState<any[]>([]);
17
+ const [showMentions, setShowMentions] = useState(false);
18
+ const [contextFiles, setContextFiles] = useState<string[]>([]);
19
+ const chatEnd = useRef<HTMLDivElement>(null);
20
+ const inputRef = useRef<HTMLInputElement>(null);
21
+
22
+ const send = async () => {
23
+ if (!input.trim() || loading) return;
24
+ const userMsg = input;
25
+ setInput('');
26
+ setMessages(prev => [...prev, { role: 'user', content: userMsg }]);
27
+ setLoading(true);
28
+
29
+ const assistantMsgRef = { current: '' };
30
+ setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
31
+
32
+ try {
33
+ const response = await fetch('/api/chat', {
34
+ method: 'POST',
35
+ headers: { 'Content-Type': 'application/json' },
36
+ body: JSON.stringify({
37
+ messages: [...messages, { role: 'user', content: userMsg }],
38
+ project_path: projectPath,
39
+ model: model,
40
+ context_files: contextFiles,
41
+ }),
42
+ });
43
+
44
+ if (!response.ok) throw new Error(`Server responded with ${response.status}`);
45
+
46
+ const reader = response.body?.getReader();
47
+ if (!reader) throw new Error('No response body');
48
+
49
+ const decoder = new TextDecoder();
50
+ let buffer = '';
51
+
52
+ while (true) {
53
+ const { done, value } = await reader.read();
54
+ if (done) break;
55
+ buffer += decoder.decode(value, { stream: true });
56
+ const lines = buffer.split('\n');
57
+ buffer = lines.pop() || '';
58
+ for (const line of lines) {
59
+ if (line.startsWith('data: ')) {
60
+ const data = line.slice(6);
61
+ if (data === '[DONE]') break;
62
+ try {
63
+ const { token } = JSON.parse(data);
64
+ if (token.startsWith('ERROR:')) {
65
+ assistantMsgRef.current = `❌ ${token.substring(7)}`;
66
+ setMessages(prev => {
67
+ const copy = [...prev];
68
+ copy[copy.length - 1] = { role: 'assistant', content: assistantMsgRef.current };
69
+ return copy;
70
+ });
71
+ return;
72
+ }
73
+ assistantMsgRef.current += token;
74
+ setMessages(prev => {
75
+ const copy = [...prev];
76
+ copy[copy.length - 1] = { role: 'assistant', content: assistantMsgRef.current };
77
+ return copy;
78
+ });
79
+ } catch (e) {
80
+ assistantMsgRef.current = `Parse error: ${data}`;
81
+ setMessages(prev => {
82
+ const copy = [...prev];
83
+ copy[copy.length - 1] = { role: 'assistant', content: assistantMsgRef.current };
84
+ return copy;
85
+ });
86
+ return;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ } catch (err: any) {
92
+ setMessages(prev => {
93
+ const copy = [...prev];
94
+ copy[copy.length - 1] = { role: 'assistant', content: `❌ ${err.message}` };
95
+ return copy;
96
+ });
97
+ } finally {
98
+ setLoading(false);
99
+ }
100
+ };
101
+
102
+ useEffect(() => { chatEnd.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
103
+
104
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
105
+ const value = e.target.value;
106
+ setInput(value);
107
+
108
+ const atIndex = value.lastIndexOf('@');
109
+ if (atIndex !== -1 && (atIndex === 0 || value[atIndex - 1] === ' ')) {
110
+ const query = value.slice(atIndex + 1).toLowerCase();
111
+ setMentionQuery(query);
112
+
113
+ if ('git'.startsWith(query)) {
114
+ setMentionItems([{ name: 'git (current diff)', type: 'special', path: '__git__' }]);
115
+ setShowMentions(true);
116
+ return;
117
+ }
118
+
119
+ axios.get('/api/files', { params: { project_path: projectPath } })
120
+ .then(res => {
121
+ const flatten = (nodes: any[]): any[] => {
122
+ let result: any[] = [];
123
+ for (const node of nodes) {
124
+ if (node.name.toLowerCase().includes(query))
125
+ result.push(node);
126
+ if (node.children) result = result.concat(flatten(node.children));
127
+ }
128
+ return result;
129
+ };
130
+ const matches = flatten(res.data).slice(0, 5);
131
+ setMentionItems(matches);
132
+ setShowMentions(matches.length > 0);
133
+ })
134
+ .catch(() => setShowMentions(false));
135
+ } else {
136
+ setShowMentions(false);
137
+ }
138
+ };
139
+
140
+ const insertMention = (item: any) => {
141
+ const atIndex = input.lastIndexOf('@');
142
+ const newInput = input.slice(0, atIndex) + item.name + ' ';
143
+ setInput(newInput);
144
+ setShowMentions(false);
145
+ if (item.path === '__git__') {
146
+ if (!contextFiles.includes('__git__')) {
147
+ setContextFiles(prev => [...prev, '__git__']);
148
+ }
149
+ } else if (!contextFiles.includes(item.path)) {
150
+ setContextFiles(prev => [...prev, item.path]);
151
+ }
152
+ inputRef.current?.focus();
153
+ };
154
+
155
+ return (
156
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
157
+ <div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
158
+ {messages.map((m, i) => (
159
+ <div key={i} style={{ marginBottom: 16, display: 'flex', justifyContent: m.role === 'user' ? 'flex-end' : 'flex-start' }}>
160
+ <div style={{
161
+ maxWidth: '80%',
162
+ background: m.role === 'user' ? 'var(--accent)' : 'var(--bg-tertiary)',
163
+ color: m.role === 'user' ? 'white' : 'var(--text-primary)',
164
+ borderRadius: 12,
165
+ borderBottomRightRadius: m.role === 'user' ? 4 : 12,
166
+ borderBottomLeftRadius: m.role === 'user' ? 12 : 4,
167
+ padding: 12,
168
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
169
+ }}>
170
+ <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)' }}>
171
+ {m.role === 'user' ? <FaUser size={10} /> : <FaRobot size={10} />}
172
+ {m.role === 'user' ? 'You' : 'Codeki'}
173
+ </div>
174
+ <ReactMarkdown>{m.content}</ReactMarkdown>
175
+ </div>
176
+ </div>
177
+ ))}
178
+ {loading && (
179
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--text-secondary)', padding: 8 }}>
180
+ <FaSpinner className="spin" /> Codeki is thinking...
181
+ </div>
182
+ )}
183
+ <div ref={chatEnd} />
184
+ </div>
185
+ <div style={{ borderTop: '1px solid var(--border)', padding: 12, background: 'var(--bg-secondary)', position: 'relative' }}>
186
+ {showMentions && (
187
+ <div style={{
188
+ position: 'absolute',
189
+ bottom: '100%',
190
+ left: 0,
191
+ right: 0,
192
+ background: 'var(--bg-tertiary)',
193
+ border: '1px solid var(--border)',
194
+ borderRadius: 4,
195
+ maxHeight: 150,
196
+ overflowY: 'auto',
197
+ marginBottom: 4,
198
+ zIndex: 10,
199
+ }}>
200
+ {mentionItems.map(item => (
201
+ <div
202
+ key={item.path}
203
+ onClick={() => insertMention(item)}
204
+ style={{ padding: '4px 8px', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6 }}
205
+ >
206
+ {item.type === 'folder' ? 'πŸ“' : item.type === 'special' ? 'πŸ”—' : 'πŸ“„'} {item.name}
207
+ </div>
208
+ ))}
209
+ </div>
210
+ )}
211
+ <div style={{ display: 'flex', gap: 8 }}>
212
+ <input
213
+ ref={inputRef}
214
+ style={{ flex: 1 }}
215
+ value={input}
216
+ onChange={handleInputChange}
217
+ onKeyDown={e => e.key === 'Enter' && send()}
218
+ placeholder="Ask anything... Type @ to reference a file or @git"
219
+ disabled={loading}
220
+ />
221
+ <button
222
+ onClick={send}
223
+ disabled={loading}
224
+ style={{
225
+ background: loading ? 'var(--border)' : 'var(--accent)',
226
+ color: 'white',
227
+ padding: '0 20px',
228
+ borderRadius: 6,
229
+ fontWeight: 600,
230
+ transition: 'background 0.2s'
231
+ }}
232
+ >
233
+ {loading ? '...' : 'Send'}
234
+ </button>
235
+ </div>
236
+ {contextFiles.length > 0 && (
237
+ <div style={{ marginTop: 6, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
238
+ {contextFiles.map(f => (
239
+ <span key={f} style={{ background: 'var(--accent)', color: 'white', padding: '2px 8px', borderRadius: 10, fontSize: 11 }}>
240
+ πŸ“Ž {f === '__git__' ? 'Git Diff' : f.split('/').pop()}
241
+ <span style={{ marginLeft: 4, cursor: 'pointer' }} onClick={() => setContextFiles(prev => prev.filter(x => x !== f))}>Γ—</span>
242
+ </span>
243
+ ))}
244
+ </div>
245
+ )}
246
+ </div>
247
+ </div>
248
+ );
249
+ };
250
+
251
+ export default ChatPanel;