Wip web interface

This commit is contained in:
Niels Geens
2025-04-17 22:09:15 +02:00
parent 813c6a3f8f
commit 023f6bba9a
13 changed files with 1040 additions and 1040 deletions

View File

@@ -180,4 +180,5 @@ class AutoApprover:
return return
if (self._response_enabled and if (self._response_enabled and
self.agent.llms[self._llm_name] == LlmState.IDLE): self.agent.llms[self._llm_name] == LlmState.IDLE):
self.agent.approve_response(self._llm_name, self.agent.get_output(self._llm_name)) response = self.agent.response_buffer.get_text()
self.agent.approve_response()

View File

@@ -94,6 +94,8 @@ class Api:
"""Approve current buffer content""" """Approve current buffer content"""
data = await request.json() data = await request.json()
try: try:
response = data.get("response")
self._agent.response_buffer.set_text(response)
self._agent.approve_response() self._agent.approve_response()
return web.Response(status=200) return web.Response(status=200)
except ValueError as e: except ValueError as e:
@@ -147,10 +149,10 @@ class Api:
"""Get all LLMs and their current states.""" """Get all LLMs and their current states."""
states = self._agent.llms states = self._agent.llms
return web.Response( return web.Response(
text=json.dumps({ text=json.dumps(
name: state.name [{"name": name, "state": state.name}
for name, state in states.items() for name, state in states.items()]
}), ),
content_type="application/json" content_type="application/json"
) )
@@ -234,10 +236,12 @@ class Api:
"""Get complete working memory state.""" """Get complete working memory state."""
entries = self._working_memory.get_entries() entries = self._working_memory.get_entries()
return web.Response( return web.Response(
text=json.dumps([e.serialize() for e in entries]), text=json.dumps({
"type": "memory_state",
"entries": [e.serialize() for e in entries]
}),
content_type="application/json" content_type="application/json"
) )
async def _create_entry(self, request: web.Request) -> web.Response: async def _create_entry(self, request: web.Request) -> web.Response:
"""Create a new entry in working memory.""" """Create a new entry in working memory."""
data = await request.json() data = await request.json()

View File

@@ -12,7 +12,7 @@ class ResponseWebSocket:
async def _handle_buffer_change(self, buffer): async def _handle_buffer_change(self, buffer):
await self._broadcast_message({ await self._broadcast_message({
"buffer": buffer, "response": buffer,
}) })
async def _broadcast_message(self, message): async def _broadcast_message(self, message):
@@ -32,7 +32,7 @@ class ResponseWebSocket:
try: try:
await ws.send_json({ await ws.send_json({
"buffer": self._agent.response_buffer.get_text(), "response": self._agent.response_buffer.get_text(),
}) })
async for msg in ws: async for msg in ws:

View File

@@ -22,7 +22,7 @@ class Websockets:
app.router.add_get("/ws/auto_approver", self._auto_approver_ws.handle_connection) app.router.add_get("/ws/auto_approver", self._auto_approver_ws.handle_connection)
app.router.add_get("/ws/context", self._context_ws.handle_connection) app.router.add_get("/ws/context", self._context_ws.handle_connection)
app.router.add_get("/ws/llm", self._llm_ws.handle_connection) app.router.add_get("/ws/llms", self._llm_ws.handle_connection)
app.router.add_get("/ws/memory", self._memory_ws.handle_connection) app.router.add_get("/ws/memory", self._memory_ws.handle_connection)
app.router.add_get("/ws/response", self._response_ws.handle_connection) app.router.add_get("/ws/response", self._response_ws.handle_connection)
app.router.add_get("/ws/stdout", self._stdout_ws.handle_connection) app.router.add_get("/ws/stdout", self._stdout_ws.handle_connection)

View File

@@ -132,6 +132,8 @@ class WebAgent(BaseAgent):
def approve_response(self) -> None: def approve_response(self) -> None:
"""Process approved response from specified LLM""" """Process approved response from specified LLM"""
if self.llms.get(llm_name) != LlmState.IDLE:
return
timestamp = datetime.now(timezone.utc) timestamp = datetime.now(timezone.utc)
self._iteration_logger.log_iteration(timestamp, self._context, self._response_buffer.get_text()) self._iteration_logger.log_iteration(timestamp, self._context, self._response_buffer.get_text())
parse_result = self._parser.parse(timestamp, self._response_buffer.get_text()) parse_result = self._parser.parse(timestamp, self._response_buffer.get_text())

View File

@@ -8,9 +8,8 @@ import { StandardEditor } from '@/components/editors/StandardEditor';
import { useWebSocket, WebSocketState } from '@/hooks/useWebSocket'; import { useWebSocket, WebSocketState } from '@/hooks/useWebSocket';
export const LlmState = { export const LlmState = {
NO_OUTPUT: 'NO_OUTPUT', IDLE: 'IDLE',
INFERENCE: 'INFERENCE', INFERENCE: 'INFERENCE'
OUTPUT: 'OUTPUT'
}; };
const App = () => { const App = () => {
@@ -18,8 +17,8 @@ const App = () => {
const [generatedContext, setGeneratedContext] = useState(''); const [generatedContext, setGeneratedContext] = useState('');
const [modifiedContext, setModifiedContext] = useState(''); const [modifiedContext, setModifiedContext] = useState('');
const [contextDirty, setContextDirty] = useState(false); const [contextDirty, setContextDirty] = useState(false);
const [generatedResponses, setGeneratedResponses] = useState({}); const [generatedResponse, setGeneratedResponse] = useState('');
const [modifiedResponses, setModifiedResponses] = useState({}); const [modifiedResponse, setModifiedResponse] = useState('');
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [output, setOutput] = useState(''); const [output, setOutput] = useState('');
@@ -44,9 +43,9 @@ const App = () => {
// WebSocket connections // WebSocket connections
const wsRoot = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws` const wsRoot = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`
const llmWs = useWebSocket(`${wsRoot}/llm`); const llmWs = useWebSocket(`${wsRoot}/llms`);
const contextWs = useWebSocket(`${wsRoot}/context`); const contextWs = useWebSocket(`${wsRoot}/context`);
const tokenWs = useWebSocket(`${wsRoot}/token`); const responseWs = useWebSocket(`${wsRoot}/response`);
const stdoutWs = useWebSocket(`${wsRoot}/stdout`); const stdoutWs = useWebSocket(`${wsRoot}/stdout`);
const autoApproverWs = useWebSocket(`${wsRoot}/auto_approver`); const autoApproverWs = useWebSocket(`${wsRoot}/auto_approver`);
const memoryWs = useWebSocket(`${wsRoot}/memory`); const memoryWs = useWebSocket(`${wsRoot}/memory`);
@@ -60,18 +59,6 @@ const App = () => {
...prevLlms, ...prevLlms,
[data.llm]: data.state [data.llm]: data.state
})); }));
if (data.state === LlmState.NO_OUTPUT) {
setGeneratedResponses(prev => {
const updated = { ...prev };
delete updated[data.llm];
return updated;
});
setModifiedResponses(prev => {
const updated = { ...prev };
delete updated[data.llm];
return updated;
});
}
}); });
}, []); }, []);
@@ -86,17 +73,11 @@ const App = () => {
}); });
}, []); }, []);
// Handle incoming tokens // Handle response changes
useEffect(() => { useEffect(() => {
tokenWs.addMessageHandler((data) => { responseWs.addMessageHandler((data) => {
setGeneratedResponses(prev => ({ setModifiedResponse(data.response);
...prev, setGeneratedResponse(data.response);
[data.llm]: (prev[data.llm] || '') + data.token
}));
setModifiedResponses(prev => ({
...prev,
[data.llm]: (prev[data.llm] || '') + data.token
}));
}); });
}, []); }, []);
@@ -138,28 +119,33 @@ const App = () => {
}; };
const handleApprove = () => { const handleApprove = () => {
const state = llms[activeLlm]; fetch('/api/response/approve', {
if (state === LlmState.NO_OUTPUT) { method: 'POST',
if (contextDirty) { headers: { 'Content-Type': 'application/json' },
fetch('/api/context', { body: JSON.stringify({ response: modifiedResponse })
method: 'POST', });
headers: { 'Content-Type': 'application/json' }, };
body: JSON.stringify({ context: modifiedContext })
}); const handleInference = () => {
} fetch('/api/context', {
fetch(`/api/inference/${activeLlm}`, { method: 'POST',
method: 'POST', headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({ context: modifiedContext })
} else if (state === LlmState.OUTPUT) { });
fetch(`/api/approve/${activeLlm}`, {
method: 'POST', fetch('/api/response', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ response: modifiedResponses[activeLlm] }) headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify({ response: modifiedResponse })
} });
fetch(`/api/inference/${activeLlm}`, {
method: 'POST',
});
}; };
const handleStop = () => { const handleStop = () => {
// We still specify which LLM to stop inference for
fetch(`/api/inference/${activeLlm}/stop`, { fetch(`/api/inference/${activeLlm}/stop`, {
method: 'POST' method: 'POST'
}); });
@@ -182,14 +168,22 @@ const App = () => {
const handleContextEdit = (context) => { const handleContextEdit = (context) => {
setContextDirty(true); setContextDirty(true);
setGeneratedResponses({}); // When context is edited, we need to reset all LLM states to IDLE
setModifiedResponses({}); // since responses are no longer valid
const resetLlms = {}; const resetLlms = {};
for (const llm of Object.keys(llms)) { for (const llm of Object.keys(llms)) {
resetLlms[llm] = LlmState.NO_OUTPUT; resetLlms[llm] = LlmState.IDLE;
} }
setLlms(resetLlms); setLlms(resetLlms);
setModifiedContext(context); setModifiedContext(context);
// Reset response buffers
setGeneratedResponse('');
setModifiedResponse('');
};
const handleResponseEdit = (response) => {
setModifiedResponse(response);
}; };
const handleAutoApproverConfigChange = async (config) => { const handleAutoApproverConfigChange = async (config) => {
@@ -246,37 +240,21 @@ const App = () => {
if (!response.ok) throw new Error('Failed to update entry'); if (!response.ok) throw new Error('Failed to update entry');
}; };
// Update active tab on state change // Determine active LLM's current state for UI feedback
useEffect(() => { const activeLlmState = activeLlm ? llms[activeLlm] : null;
if (activeTab === Tabs.MEMORY) return;
const state = llms[activeLlm];
switch (state) {
case null:
break;
case LlmState.NO_OUTPUT:
setActiveTab(Tabs.CONTEXT);
setShowDiff(false);
setShowSidebar(false);
break;
default:
setActiveTab(Tabs.RESPONSE);
setShowDiff(false);
setShowSidebar(false);
break;
}
}, [llms[activeLlm]]);
// Track overall WebSocket connection state
useEffect(() => { useEffect(() => {
if (llmWs.wsState === WebSocketState.CONNECTED && if (llmWs.wsState === WebSocketState.CONNECTED &&
contextWs.wsState === WebSocketState.CONNECTED && contextWs.wsState === WebSocketState.CONNECTED &&
tokenWs.wsState === WebSocketState.CONNECTED && responseWs.wsState === WebSocketState.CONNECTED &&
stdoutWs.wsState === WebSocketState.CONNECTED && stdoutWs.wsState === WebSocketState.CONNECTED &&
autoApproverWs.wsState === WebSocketState.CONNECTED && autoApproverWs.wsState === WebSocketState.CONNECTED &&
memoryWs.wsState === WebSocketState.CONNECTED) { memoryWs.wsState === WebSocketState.CONNECTED) {
setWsState(WebSocketState.CONNECTED); setWsState(WebSocketState.CONNECTED);
} else if (llmWs.wsState === WebSocketState.CONNECTING || } else if (llmWs.wsState === WebSocketState.CONNECTING ||
contextWs.wsState === WebSocketState.CONNECTING || contextWs.wsState === WebSocketState.CONNECTING ||
tokenWs.wsState === WebSocketState.CONNECTING || responseWs.wsState === WebSocketState.CONNECTING ||
stdoutWs.wsState === WebSocketState.CONNECTING || stdoutWs.wsState === WebSocketState.CONNECTING ||
autoApproverWs.wsState === WebSocketState.CONNECTING || autoApproverWs.wsState === WebSocketState.CONNECTING ||
memoryWs.wsState === WebSocketState.CONNECTING) { memoryWs.wsState === WebSocketState.CONNECTING) {
@@ -284,13 +262,13 @@ const App = () => {
} else { } else {
setWsState(WebSocketState.DISCONNECTED); setWsState(WebSocketState.DISCONNECTED);
} }
}, [llmWs.wsState, contextWs.wsState, tokenWs.wsState, stdoutWs.wsState, autoApproverWs.wsState, memoryWs.wsState]); }, [llmWs.wsState, contextWs.wsState, responseWs.wsState, stdoutWs.wsState, autoApproverWs.wsState, memoryWs.wsState]);
return ( return (
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden"> <div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
<Header <Header
wsState={wsState} wsState={wsState}
agentState={llms[activeLlm]} agentState={activeLlmState}
activeTab={activeTab} activeTab={activeTab}
setActiveTab={setActiveTab} setActiveTab={setActiveTab}
/> />
@@ -309,12 +287,9 @@ const App = () => {
{activeTab === Tabs.RESPONSE && ( {activeTab === Tabs.RESPONSE && (
<ContentEditor <ContentEditor
showDiff={showDiff} showDiff={showDiff}
originalContent={generatedResponses[activeLlm] || ''} originalContent={generatedResponse}
modifiedContent={modifiedResponses[activeLlm] || ''} modifiedContent={modifiedResponse}
onChange={value => setModifiedResponses(prev => ({ onChange={handleResponseEdit}
...prev,
[activeLlm]: value
}))}
/> />
)} )}
{activeTab === Tabs.INPUT && ( {activeTab === Tabs.INPUT && (
@@ -347,14 +322,15 @@ const App = () => {
showSidebar={showSidebar} showSidebar={showSidebar}
llms={llms} llms={llms}
activeLlm={activeLlm} activeLlm={activeLlm}
llmState={llms[activeLlm]} llmState={activeLlmState}
showDiff={showDiff} showDiff={showDiff}
autoScroll={autoScroll} autoScroll={autoScroll}
onAutoScrollChange={(enabled) => setAutoScroll(enabled)} onAutoScrollChange={setAutoScroll}
input={input} input={input}
output={output} output={output}
autoApproverConfig={autoApproverConfig} autoApproverConfig={autoApproverConfig}
onApprove={handleApprove} onApprove={handleApprove}
onInference={handleInference}
onStop={handleStop} onStop={handleStop}
onToggleDiff={() => setShowDiff(!showDiff)} onToggleDiff={() => setShowDiff(!showDiff)}
onSendInput={handleSendInput} onSendInput={handleSendInput}

View File

@@ -7,7 +7,6 @@ export const Tabs = {
INPUT: 'INPUT', INPUT: 'INPUT',
OUTPUT: 'OUTPUT', OUTPUT: 'OUTPUT',
MEMORY: 'MEMORY' MEMORY: 'MEMORY'
}; };
export const Header = ({ export const Header = ({
@@ -31,9 +30,11 @@ export const Header = ({
}`}> }`}>
{wsState} {wsState}
</div> </div>
<div className="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-sm"> {agentState && (
{agentState} <div className="px-3 py-1 rounded-full bg-blue-100 text-blue-800 text-sm">
</div> {agentState}
</div>
)}
</div> </div>
</div> </div>
<div className="border-t"> <div className="border-t">
@@ -48,7 +49,7 @@ export const Header = ({
: 'border-transparent text-gray-500 hover:text-gray-700' : 'border-transparent text-gray-500 hover:text-gray-700'
}`} }`}
> >
{tab.charAt(0).toUpperCase() + tab.slice(1)} {tab.charAt(0).toUpperCase() + tab.slice(1).toLowerCase()}
</button> </button>
))} ))}
</div> </div>

View File

@@ -16,6 +16,7 @@ export const Sidebar = ({
output, output,
autoApproverConfig, autoApproverConfig,
onApprove, onApprove,
onInference,
onStop, onStop,
onToggleDiff, onToggleDiff,
onSendInput, onSendInput,
@@ -61,15 +62,27 @@ export const Sidebar = ({
/> />
</div> </div>
{llmState === LlmState.INFERENCE ? (
<Button
onClick={onStop}
className="w-full"
>
Stop
</Button>
) : (
<Button
onClick={onInference}
className="w-full"
>
Start Inference
</Button>
)}
<Button <Button
onClick={llmState === LlmState.INFERENCE ? onStop : onApprove} onClick={onApprove}
className="w-full" className="w-full"
> >
{ Approve Response
llmState === LlmState.NO_OUTPUT ? 'Inference' :
llmState === LlmState.INFERENCE ? 'Stop' :
'Approve'
}
</Button> </Button>
<Button onClick={onSendInput} disabled={!input.trim()} className="w-full"> <Button onClick={onSendInput} disabled={!input.trim()} className="w-full">
@@ -136,7 +149,7 @@ export const Sidebar = ({
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm">Auto-Approve LLM</label> <label className="text-sm">LLM for Inference</label>
<Select <Select
value={autoApproverConfig.llm_name} value={autoApproverConfig.llm_name}
onValueChange={(llm) => onAutoApproverConfigChange({ onValueChange={(llm) => onAutoApproverConfigChange({
@@ -153,6 +166,9 @@ export const Sidebar = ({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-gray-500 mt-1">
This selects which model will generate content during auto-approval.
</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -4,58 +4,58 @@ import { Card, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
export const StandardEditor = ({ export const StandardEditor = ({
content, content,
onChange, onChange,
readOnly = false, readOnly = false,
language = 'xml', language = 'xml',
validationError = null, validationError = null,
autoScroll = false, autoScroll = false,
}) => { }) => {
const editorRef = React.useRef(null); const editorRef = React.useRef(null);
const scrollToBottom = () => { const scrollToBottom = () => {
if (editorRef.current && autoScroll) { if (editorRef.current && autoScroll) {
const model = editorRef.current.getModel(); const model = editorRef.current.getModel();
const lineCount = model.getLineCount(); const lineCount = model.getLineCount();
editorRef.current.revealLine(lineCount, monaco.editor.ScrollType.Smooth); editorRef.current.revealLine(lineCount, monaco.editor.ScrollType.Smooth);
} }
}; };
const handleEditorDidMount = (editor) => { const handleEditorDidMount = (editor) => {
editorRef.current = editor; editorRef.current = editor;
setTimeout(() => { setTimeout(() => {
scrollToBottom(); scrollToBottom();
}, 10); }, 10);
}; };
React.useEffect(() => { React.useEffect(() => {
scrollToBottom(); scrollToBottom();
}, [content, autoScroll]); }, [content, autoScroll]);
return ( return (
<Card className="h-[calc(100vh-8rem)]"> <Card className="h-[calc(100vh-8rem)]">
<CardContent className="p-0 h-full"> <CardContent className="p-0 h-full">
{validationError && ( {validationError && (
<Alert variant="destructive" className="m-4"> <Alert variant="destructive" className="m-4">
<AlertDescription>{validationError}</AlertDescription> <AlertDescription>{validationError}</AlertDescription>
</Alert> </Alert>
)} )}
<Editor <Editor
height="100%" height="100%"
language={language} language={language}
value={content} value={content}
onChange={onChange} onChange={onChange}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: false },
lineNumbers: 'on', lineNumbers: 'on',
readOnly, readOnly,
fontSize: 14, fontSize: 14,
automaticLayout: true, automaticLayout: true,
wordWrap: 'on', wordWrap: 'on',
}} }}
/> />
</CardContent> </CardContent>
</Card> </Card>
); );
}; };

View File

@@ -1,68 +1,68 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
export const WebSocketState = { export const WebSocketState = {
CONNECTING: 'CONNECTING', CONNECTING: 'CONNECTING',
CONNECTED: 'CONNECTED', CONNECTED: 'CONNECTED',
DISCONNECTED: 'DISCONNECTED', DISCONNECTED: 'DISCONNECTED',
}; };
export const useWebSocket = (url) => { export const useWebSocket = (url) => {
const [wsState, setWsState] = useState(WebSocketState.DISCONNECTED); const [wsState, setWsState] = useState(WebSocketState.DISCONNECTED);
const wsRef = useRef(null); const wsRef = useRef(null);
const handlersRef = useRef([]); const handlersRef = useRef([]);
const connect = useCallback(() => { const connect = useCallback(() => {
setWsState(WebSocketState.CONNECTING); setWsState(WebSocketState.CONNECTING);
const ws = new WebSocket(url); const ws = new WebSocket(url);
wsRef.current = ws; wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
setWsState(WebSocketState.CONNECTED); setWsState(WebSocketState.CONNECTED);
}; };
ws.onclose = () => { ws.onclose = () => {
setWsState(WebSocketState.DISCONNECTED); setWsState(WebSocketState.DISCONNECTED);
setTimeout(connect, 2000); setTimeout(connect, 2000);
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error:', error); console.error('WebSocket error:', error);
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
handlersRef.current.forEach(handler => handler(message)); handlersRef.current.forEach(handler => handler(message));
} catch (error) { } catch (error) {
console.error('Error handling WebSocket message:', error); console.error('Error handling WebSocket message:', error);
} }
}; };
return ws; return ws;
}, [url]); }, [url]);
useEffect(() => { useEffect(() => {
const ws = connect(); const ws = connect();
return () => { return () => {
if (ws) ws.close(); if (ws) ws.close();
}; };
}, [connect]); }, [connect]);
const sendMessage = useCallback((data) => { const sendMessage = useCallback((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data)); wsRef.current.send(JSON.stringify(data));
} else { } else {
console.error('WebSocket not connected, state:', wsRef.current?.readyState); console.error('WebSocket not connected, state:', wsRef.current?.readyState);
} }
}, []); }, []);
const addMessageHandler = useCallback((handler) => { const addMessageHandler = useCallback((handler) => {
handlersRef.current.push(handler); handlersRef.current.push(handler);
}, []); }, []);
return { return {
wsState, wsState,
sendMessage, sendMessage,
addMessageHandler addMessageHandler
}; };
}; };