Stub web project

This commit is contained in:
2024-11-02 11:55:03 +01:00
parent 17a70e08d5
commit 092a05c3aa
19 changed files with 324 additions and 36 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
pdf/ pdf/
model/ model/
claude.txt

View File

@@ -1,19 +1,35 @@
FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS requirements FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS requirements
RUN apt-get update RUN apt-get update
RUN apt-get update -y RUN apt-get upgrade -y
RUN apt install -y python3-pip RUN apt install -y python3-pip
ENV TOKENIZERS_PARALLELISM=false ENV TOKENIZERS_PARALLELISM=false
COPY requirements.txt /requirements.txt COPY requirements.txt /requirements.txt
RUN pip3 install -r /requirements.txt RUN pip3 install -r /requirements.txt
RUN rm -rf /requirements.txt RUN rm -rf /requirements.txt
FROM requirements AS test FROM requirements AS sia-test
COPY ./ /root/sia/ COPY ./ /root/sia/
WORKDIR /root/sia/ WORKDIR /root/sia/
RUN mkdir -p /root/model RUN mkdir -p /root/model
CMD ["python3", "-m", "unittest", "discover", "-v", "-p", "*test.py", "-v"] CMD ["python3", "-m", "unittest", "discover", "-v", "-p", "*test.py", "-v"]
FROM node:20-alpine AS web-test
WORKDIR /app
COPY web/package*.json ./
RUN npm install
COPY web .
RUN npm test
FROM node:20-alpine AS web-build
WORKDIR /app
COPY web/package*.json ./
RUN npm install
COPY web .
RUN npm run build
FROM requirements FROM requirements
COPY ./ /root/sia/ COPY ./ /root/sia/
WORKDIR /root/sia/ COPY --from=web-build /app/dist /root/sia/static/
WORKDIR /root/sia
CMD ["python3", "-m", "sia"] CMD ["python3", "-m", "sia"]

53
claude.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/bin/bash
# Check if we're in a git repository
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "Not in a git repository. Creating temporary one to use gitignore rules..."
# Initialize temporary git repo if not in one
git init >/dev/null 2>&1
temp_git=true
else
temp_git=false
fi
# Clear/create output file
> claude.txt
# Generate and add directory tree
echo "Directory Tree:" > claude.txt
echo "=============" >> claude.txt
# Use tree with gitignore patterns
tree -I "$(git check-ignore * .*)" >> claude.txt
echo -e "\nFile Contents:" >> claude.txt
echo "=============" >> claude.txt
# Use git ls-files to get tracked files and untracked files that aren't ignored
# The --exclude-standard flag makes git ls-files respect .gitignore
# --others includes untracked files
# --cached includes tracked files
# -z uses null byte as separator for safer handling of filenames with spaces
(git ls-files -z --cached --others --exclude-standard) | while IFS= read -r -d '' file; do
# Skip the output file itself
if [ "$file" = "claude.txt" ]; then
continue
fi
# Skip binary files
if file "$file" | grep -q "binary"; then
echo "Skipping binary file: $file"
continue
fi
echo -e "\n=== File: $file ===" >> claude.txt
echo -e "------------------------" >> claude.txt
cat "$file" >> claude.txt
done
# Clean up temporary git repo if we created one
if [ "$temp_git" = true ]; then
rm -rf .git
echo "Cleaned up temporary git repository"
fi
echo "Concatenation complete. Output written to claude.txt"

1
run.sh
View File

@@ -19,6 +19,7 @@ docker run \
-ti \ -ti \
--gpus=all \ --gpus=all \
--privileged \ --privileged \
-p 8080:8080 \
-v /$(pwd)/model/:/root/model/ \ -v /$(pwd)/model/:/root/model/ \
$TAG $TAG

View File

@@ -39,7 +39,7 @@ async def run_web_server():
) )
# Create and start web server # Create and start web server
server = WebServer(agent, io_buffer) server = WebServer(agent, io_buffer, "0.0.0.0", 8080)
await server.start() await server.start()
try: try:

View File

@@ -1,12 +1,19 @@
from aiohttp import web, WSMsgType
from pathlib import Path
from typing import Set, Optional
import asyncio import asyncio
import json import json
from typing import Set, Optional
from aiohttp import web, WSMsgType
from dataclasses import dataclass from dataclasses import dataclass
import mimetypes
from .web_agent import WebAgent, WebAgentState from .web_agent import WebAgent, WebAgentState
from .web_io_buffer import WebIOBuffer from .web_io_buffer import WebIOBuffer
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("application/javascript", ".jsx")
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("text/javascript", ".jsx")
@dataclass @dataclass
class ClientMessage: class ClientMessage:
"""Messages that can be received from clients.""" """Messages that can be received from clients."""
@@ -27,16 +34,18 @@ class ServerMessage:
class WebServer: class WebServer:
""" """
AIOHTTP-based web server that manages WebSocket connections and AIOHTTP-based web server that manages WebSocket connections and
integrates with the WebAgent. integrates with the WebAgent. Serves static files from a web directory.
""" """
def __init__(self, agent: WebAgent, io_buffer: WebIOBuffer,
def __init__(self, agent: WebAgent, io_buffer: WebIOBuffer, host: str = "localhost", port: int = 8080): host: str = "0.0.0.0", port: int = 8080,
static_dir: Optional[Path] = None):
"""Initialize the web server.""" """Initialize the web server."""
self.agent = agent self.agent = agent
self.io_buffer = io_buffer self.io_buffer = io_buffer
self.host = host self.host = host
self.port = port self.port = port
self.app = web.Application() self.app = web.Application()
self.static_dir = static_dir or Path(__file__).parent.parent / "static"
self._init_routes() self._init_routes()
self._clients: Set[web.WebSocketResponse] = set() self._clients: Set[web.WebSocketResponse] = set()
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
@@ -48,8 +57,48 @@ class WebServer:
def _init_routes(self): def _init_routes(self):
"""Initialize application routes.""" """Initialize application routes."""
self.app.middlewares.append(self._cors_middleware)
self.app.router.add_get("/ws", self._handle_websocket) self.app.router.add_get("/ws", self._handle_websocket)
if self.static_dir.exists():
self.app.router.add_get("/", self._serve_index)
self.app.router.add_static("/static/", self.static_dir, show_index=False)
self.app.router.add_static("/assets/", self.static_dir / "assets", show_index=False)
self.app.router.add_get("/{path:.*}", self._serve_index)
async def _serve_index(self, request: web.Request) -> web.Response:
"""
Serve the React application HTML for any unmatched routes to support
client-side routing.
"""
index_path = self.static_dir / "index.html"
if not index_path.exists():
raise web.HTTPNotFound()
with open(index_path, "r") as f:
html_content = f.read()
return web.Response(
text=html_content,
content_type="text/html"
)
@web.middleware
async def _cors_middleware(self, request: web.Request, handler):
"""Handle CORS headers."""
if request.method == "OPTIONS":
response = web.Response()
else:
response = await handler(request)
response.headers.update({
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
})
return response
@property @property
def clients(self) -> Set[web.WebSocketResponse]: def clients(self) -> Set[web.WebSocketResponse]:
"""Get the set of connected clients.""" """Get the set of connected clients."""
@@ -57,11 +106,17 @@ class WebServer:
async def start(self): async def start(self):
"""Start the web server.""" """Start the web server."""
# Verify static directory exists
if not self.static_dir.exists():
raise FileNotFoundError(f"Static directory not found: {self.static_dir}")
self.runner = web.AppRunner(self.app) self.runner = web.AppRunner(self.app)
await self.runner.setup() await self.runner.setup()
site = web.TCPSite(self.runner, self.host, self.port) site = web.TCPSite(self.runner, self.host, self.port)
await site.start() await site.start()
print(f"Web server started at ws://{self.host}:{self.port}/ws") print(f"Web server started at http://{self.host}:{self.port}")
print(f"Serving static files from: {self.static_dir}")
print(f"WebSocket endpoint at ws://{self.host}:{self.port}/ws")
async def stop(self): async def stop(self):
"""Stop the web server and clean up resources.""" """Stop the web server and clean up resources."""
@@ -70,11 +125,13 @@ class WebServer:
async def _send_to_client(self, ws: web.WebSocketResponse, message: dict) -> bool: async def _send_to_client(self, ws: web.WebSocketResponse, message: dict) -> bool:
"""Send message to client, return True if successful.""" """Send message to client, return True if successful."""
if not ws.closed:
try: try:
await ws.send_json(message) await ws.send_json(message)
return True return True
except Exception: except Exception:
return False return False
return False
async def broadcast(self, message_type: str, data: str, **kwargs): async def broadcast(self, message_type: str, data: str, **kwargs):
"""Broadcast a message to all connected clients.""" """Broadcast a message to all connected clients."""
@@ -91,21 +148,24 @@ class WebServer:
for ws in self._clients: for ws in self._clients:
if not ws.closed: if not ws.closed:
task = asyncio.create_task(self._send_to_client(ws, message)) task = asyncio.create_task(self._send_to_client(ws, message))
tasks.append(task) tasks.append((ws, task))
else: else:
disconnected.add(ws) disconnected.add(ws)
if tasks: if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True) for ws, task in tasks:
for ws, success in zip(self._clients - disconnected, results): try:
success = await task
if not success: if not success:
disconnected.add(ws) disconnected.add(ws)
except Exception:
disconnected.add(ws)
self._clients -= disconnected self._clients -= disconnected
async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse:
"""Handle new WebSocket connections and messages.""" """Handle new WebSocket connections and messages."""
ws = web.WebSocketResponse() ws = web.WebSocketResponse(heartbeat=30)
await ws.prepare(request) await ws.prepare(request)
async with self._lock: async with self._lock:
@@ -161,7 +221,7 @@ class WebServer:
if message_type == ClientMessage.APPROVE_CONTEXT: if message_type == ClientMessage.APPROVE_CONTEXT:
if self.agent.current_state == WebAgentState.CONTEXT_APPROVAL: if self.agent.current_state == WebAgentState.CONTEXT_APPROVAL:
await self.agent.approve_context() # Make sure this is awaited await self.agent.approve_context()
elif message_type == ClientMessage.APPROVE_RESPONSE: elif message_type == ClientMessage.APPROVE_RESPONSE:
if self.agent.current_state == WebAgentState.RESPONSE_APPROVAL: if self.agent.current_state == WebAgentState.RESPONSE_APPROVAL:

View File

@@ -6,7 +6,7 @@ set -e
# Build with progress output and capture the tag # Build with progress output and capture the tag
TAG=$( \ TAG=$( \
docker build \ docker build \
--target test \ --target sia-test \
. \ . \
2>&1 | tee /dev/tty | grep "writing image" | cut -d' ' -f4 \ 2>&1 | tee /dev/tty | grep "writing image" | cut -d' ' -f4 \
) )

View File

@@ -1,23 +1,19 @@
from aiohttp import WSMsgType from aiohttp import WSMsgType
from aiohttp import WSMsgType from aiohttp import WSMsgType
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop
from pathlib import Path
from sia.web_agent import WebAgentState from sia.web_agent import WebAgentState
from typing import AsyncIterator, Iterator from unittest.mock import patch
from unittest.mock import Mock, patch
import asyncio import asyncio
import asyncio import asyncio
import json import json
import json import json
import shutil
import tempfile
from sia.llm_engine import LlmEngine
from sia.response_parser import ResponseParser
from sia.system_metrics import SystemMetrics
from sia.web_agent import WebAgentState from sia.web_agent import WebAgentState
from sia.web_io_buffer import WebIOBuffer from sia.web_io_buffer import WebIOBuffer
from sia.web_server import WebServer, ServerMessage, ClientMessage from sia.web_server import WebServer, ServerMessage, ClientMessage
from sia.working_memory import WorkingMemory
from sia.xml_validator import XMLValidator
from .mock_web_agent import MockWebAgent from .mock_web_agent import MockWebAgent
class WebServerTest(AioHTTPTestCase): class WebServerTest(AioHTTPTestCase):
@@ -25,13 +21,28 @@ class WebServerTest(AioHTTPTestCase):
"""Create application for testing.""" """Create application for testing."""
self.io_buffer = WebIOBuffer() self.io_buffer = WebIOBuffer()
# Create MockWebAgent instance
self.agent = MockWebAgent() self.agent = MockWebAgent()
# Create web server # Create temporary directory for static files
self.web_server = WebServer(self.agent, self.io_buffer, "localhost", 8080) self.temp_dir = Path(tempfile.mkdtemp())
self.static_dir = self.temp_dir / "static"
self.static_dir.mkdir(exist_ok=True)
# Create web server with temp static directory
self.web_server = WebServer(
self.agent,
self.io_buffer,
"localhost",
8080,
static_dir=self.static_dir
)
return self.web_server.app return self.web_server.app
def tearDown(self):
"""Clean up temporary directory."""
if hasattr(self, 'temp_dir'):
shutil.rmtree(self.temp_dir)
async def wait_for_messages(self, ws, count=1, timeout=5): async def wait_for_messages(self, ws, count=1, timeout=5):
"""Helper to wait for multiple WebSocket messages.""" """Helper to wait for multiple WebSocket messages."""
messages = [] messages = []

4
web/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
coverage
.gitignore

4
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
coverage
.DS_Store

12
web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

10
web/jest.config.js Normal file
View File

@@ -0,0 +1,10 @@
export default {
testEnvironment: 'jsdom',
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
}

33
web/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "my-react-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/preset-env": "^7.23.9",
"@babel/preset-react": "^7.23.9",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.0"
}
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
import { add } from '../utils/calculator.js' // Note the .js extension
function App() {
const [result, setResult] = React.useState(0)
const handleCalculate = () => {
setResult(add(5, 3))
}
return (
<div className="p-4">
<h1 className="text-2xl font-bold mb-4">React App</h1>
<button
className="bg-blue-500 text-white px-4 py-2 rounded"
onClick={handleCalculate}
>
Calculate 5 + 3
</button>
<p className="mt-4">Result: {result}</p>
</div>
)
}
export default App

View File

@@ -0,0 +1,15 @@
import { render, screen, fireEvent } from '@testing-library/react'
import App from './App'
describe('App', () => {
it('renders without crashing', () => {
render(<App />)
expect(screen.getByText(/React App/i)).toBeInTheDocument()
})
it('calculates correctly when button is clicked', () => {
render(<App />)
fireEvent.click(screen.getByText(/Calculate/i))
expect(screen.getByText(/Result: 8/i)).toBeInTheDocument()
})
})

9
web/src/index.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './components/App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,7 @@
export const add = (a, b) => a + b
export const subtract = (a, b) => a - b
export const multiply = (a, b) => a * b
export const divide = (a, b) => {
if (b === 0) throw new Error('Division by zero')
return a / b
}

View File

@@ -0,0 +1,16 @@
// calculator.test.js
import { add, subtract, multiply, divide } from './calculator.js' // Note the .js extension
describe('Calculator', () => {
describe('add', () => {
it('adds two numbers correctly', () => {
expect(add(2, 3)).toBe(5)
})
})
describe('divide', () => {
it('throws error on division by zero', () => {
expect(() => divide(1, 0)).toThrow('Division by zero')
})
})
})

11
web/vite.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
assetsDir: 'assets',
base: '/'
}
})