/** * useCodeEditor Hook * * Manages SSE connection, message state, edit proposals, and agent progress. * File references are extracted from @fileName tags in the prompt text. */ import { useState, useCallback, useRef, useEffect } from 'react'; import api from '../../../api'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../../../utils/csrfUtils'; import type { Message } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { FileInfo } from './FileListPanel'; import type { FileEditProposal } from './DiffPreviewPanel'; export interface AgentProgress { round: number; totalAiCalls: number; totalToolCalls: number; costCHF: number; } interface UseCodeEditorReturn { messages: Message[]; pendingEdits: FileEditProposal[]; acceptEdit: (editId: string) => void; rejectEdit: (editId: string) => void; isProcessing: boolean; sendMessage: (prompt: string, mode?: 'simple' | 'agent') => void; stopProcessing: () => void; files: FileInfo[]; agentProgress: AgentProgress | null; } export function useCodeEditor(instanceId: string): UseCodeEditorReturn { const [messages, setMessages] = useState([]); const [pendingEdits, setPendingEdits] = useState([]); const [isProcessing, setIsProcessing] = useState(false); const [files, setFiles] = useState([]); const [workflowId, setWorkflowId] = useState(null); const [agentProgress, setAgentProgress] = useState(null); const abortRef = useRef(null); useEffect(() => { if (!instanceId) return; _loadFiles(instanceId, setFiles); }, [instanceId]); const sendMessage = useCallback((prompt: string, mode: 'simple' | 'agent' = 'simple') => { if (!instanceId || isProcessing) return; const referencedFileIds = _extractFileRefs(prompt, files); setIsProcessing(true); setAgentProgress(null); setMessages(prev => [...prev, { id: `user-${Date.now()}`, workflowId: workflowId || '', role: 'user', message: prompt, publishedAt: Date.now() / 1000, }]); if (abortRef.current) { abortRef.current.abort(); } abortRef.current = new AbortController(); const params = new URLSearchParams(); if (workflowId) params.set('workflowId', workflowId); params.set('mode', mode); const baseURL = api.defaults.baseURL || ''; const url = `${baseURL}/api/codeeditor/${instanceId}/start/stream?${params.toString()}`; const headers: Record = { 'Content-Type': 'application/json', }; const authToken = localStorage.getItem('authToken'); if (authToken) { headers['Authorization'] = `Bearer ${authToken}`; } if (!getCSRFToken()) { generateAndStoreCSRFToken(); } addCSRFTokenToHeaders(headers); fetch(url, { method: 'POST', headers, body: JSON.stringify({ prompt: prompt, listFileId: referencedFileIds, }), credentials: 'include', signal: abortRef.current.signal, }).then(async (response) => { if (!response.ok) { const errorText = await response.text(); throw new Error(`HTTP ${response.status}: ${errorText}`); } if (!response.body) { throw new Error('Response body is null'); } const reader = response.body.getReader(); 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 jsonStr = line.slice(6); try { const event = JSON.parse(jsonStr); _handleSseEvent(event, setMessages, setPendingEdits, setWorkflowId, setAgentProgress); if (event.type === 'complete' || event.type === 'error' || event.type === 'stopped') { setIsProcessing(false); } } catch { // skip unparseable lines } } } } setIsProcessing(false); }).catch((err) => { if (err.name === 'AbortError') return; console.error('CodeEditor SSE error:', err); setMessages(prev => [...prev, { id: `error-${Date.now()}`, workflowId: '', role: 'system', message: `Connection error: ${err.message}`, publishedAt: Date.now() / 1000, }]); setIsProcessing(false); }); }, [instanceId, isProcessing, workflowId, files]); const stopProcessing = useCallback(() => { if (abortRef.current) { abortRef.current.abort(); } if (!instanceId || !workflowId) return; api.post(`/api/codeeditor/${instanceId}/${workflowId}/stop`).catch(console.error); setIsProcessing(false); }, [instanceId, workflowId]); const acceptEdit = useCallback((editId: string) => { const edit = pendingEdits.find(e => e.id === editId); if (!edit || !instanceId || !workflowId) return; api.post(`/api/codeeditor/${instanceId}/${workflowId}/apply`, { fileId: edit.fileId, fileName: edit.fileName, newContent: edit.newContent, }).then(() => { setPendingEdits(prev => prev.map(e => e.id === editId ? { ...e, status: 'accepted' as const } : e )); _loadFiles(instanceId, setFiles); }).catch(console.error); }, [pendingEdits, instanceId, workflowId]); const rejectEdit = useCallback((editId: string) => { setPendingEdits(prev => prev.map(e => e.id === editId ? { ...e, status: 'rejected' as const } : e )); }, []); return { messages, pendingEdits, acceptEdit, rejectEdit, isProcessing, sendMessage, stopProcessing, files, agentProgress, }; } function _loadFiles(instanceId: string, setFiles: React.Dispatch>) { api.get(`/api/codeeditor/${instanceId}/files`) .then(res => setFiles(res.data.files || [])) .catch(err => console.error('Failed to load files:', err)); } function _extractFileRefs(prompt: string, files: FileInfo[]): string[] { const atPattern = /@([\w.\-]+)/g; const matchedIds: string[] = []; let match; while ((match = atPattern.exec(prompt)) !== null) { const refName = match[1]; const file = files.find(f => f.fileName === refName || f.fileName.toLowerCase() === refName.toLowerCase()); if (file && !matchedIds.includes(file.fileId)) { matchedIds.push(file.fileId); } } return matchedIds; } function _handleSseEvent( event: any, setMessages: React.Dispatch>, setPendingEdits: React.Dispatch>, setWorkflowId: React.Dispatch>, setAgentProgress: React.Dispatch> ) { if (event.type === 'message' && event.item) { const item = event.item; setMessages(prev => [...prev, { id: item.id || `msg-${Date.now()}-${Math.random()}`, workflowId: item.workflowId || '', role: item.role || 'assistant', message: item.content || '', publishedAt: item.createdAt || Date.now() / 1000, documents: item.documents, }]); } else if (event.type === 'file_edit_proposal' && event.item) { setPendingEdits(prev => [...prev, event.item]); } else if (event.type === 'status') { setMessages(prev => { const lastIsStatus = prev.length > 0 && prev[prev.length - 1].role === 'status'; const statusMsg: Message = { id: `status-${Date.now()}`, workflowId: '', role: 'status', message: event.label || '', publishedAt: Date.now() / 1000, }; return lastIsStatus ? [...prev.slice(0, -1), statusMsg] : [...prev, statusMsg]; }); } else if (event.type === 'agent_progress' && event.item) { setAgentProgress(event.item); } else if (event.type === 'agent_summary' && event.item) { const s = event.item; setMessages(prev => [...prev, { id: `summary-${Date.now()}`, workflowId: '', role: 'system', message: `Agent completed: ${s.rounds} rounds, ${s.totalToolCalls} tool calls, ${s.costCHF} CHF, ${s.processingTime}s`, publishedAt: Date.now() / 1000, }]); setAgentProgress(null); } else if (event.type === 'complete' && event.workflowId) { setWorkflowId(event.workflowId); } }