260 lines
8.4 KiB
TypeScript
260 lines
8.4 KiB
TypeScript
/**
|
|
* 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<Message[]>([]);
|
|
const [pendingEdits, setPendingEdits] = useState<FileEditProposal[]>([]);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [files, setFiles] = useState<FileInfo[]>([]);
|
|
const [workflowId, setWorkflowId] = useState<string | null>(null);
|
|
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null);
|
|
const abortRef = useRef<AbortController | null>(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<string, string> = {
|
|
'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<React.SetStateAction<FileInfo[]>>) {
|
|
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<React.SetStateAction<Message[]>>,
|
|
setPendingEdits: React.Dispatch<React.SetStateAction<FileEditProposal[]>>,
|
|
setWorkflowId: React.Dispatch<React.SetStateAction<string | null>>,
|
|
setAgentProgress: React.Dispatch<React.SetStateAction<AgentProgress | null>>
|
|
) {
|
|
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);
|
|
}
|
|
}
|