diff --git a/.gitignore b/.gitignore index f8ef954..4591654 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ pdf/ -model/ \ No newline at end of file +model/ +claude.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 366f19c..dc612af 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,35 @@ FROM nvidia/cuda:12.2.0-runtime-ubuntu22.04 AS requirements RUN apt-get update -RUN apt-get update -y +RUN apt-get upgrade -y RUN apt install -y python3-pip ENV TOKENIZERS_PARALLELISM=false COPY requirements.txt /requirements.txt RUN pip3 install -r /requirements.txt RUN rm -rf /requirements.txt -FROM requirements AS test +FROM requirements AS sia-test COPY ./ /root/sia/ WORKDIR /root/sia/ RUN mkdir -p /root/model 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 COPY ./ /root/sia/ -WORKDIR /root/sia/ +COPY --from=web-build /app/dist /root/sia/static/ +WORKDIR /root/sia + CMD ["python3", "-m", "sia"] \ No newline at end of file diff --git a/claude.sh b/claude.sh new file mode 100755 index 0000000..6d692c1 --- /dev/null +++ b/claude.sh @@ -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" \ No newline at end of file diff --git a/run.sh b/run.sh index 0041bb3..088816f 100755 --- a/run.sh +++ b/run.sh @@ -19,6 +19,7 @@ docker run \ -ti \ --gpus=all \ --privileged \ + -p 8080:8080 \ -v /$(pwd)/model/:/root/model/ \ $TAG diff --git a/sia/__main__.py b/sia/__main__.py index 3625312..2ddaa58 100644 --- a/sia/__main__.py +++ b/sia/__main__.py @@ -39,7 +39,7 @@ async def run_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() try: diff --git a/sia/web_server.py b/sia/web_server.py index 27b6402..4f70eb7 100644 --- a/sia/web_server.py +++ b/sia/web_server.py @@ -1,12 +1,19 @@ +from aiohttp import web, WSMsgType +from pathlib import Path +from typing import Set, Optional import asyncio import json -from typing import Set, Optional -from aiohttp import web, WSMsgType from dataclasses import dataclass +import mimetypes from .web_agent import WebAgent, WebAgentState 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 class ClientMessage: """Messages that can be received from clients.""" @@ -27,16 +34,18 @@ class ServerMessage: class WebServer: """ 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, host: str = "localhost", port: int = 8080): + def __init__(self, agent: WebAgent, io_buffer: WebIOBuffer, + host: str = "0.0.0.0", port: int = 8080, + static_dir: Optional[Path] = None): """Initialize the web server.""" self.agent = agent self.io_buffer = io_buffer self.host = host self.port = port self.app = web.Application() + self.static_dir = static_dir or Path(__file__).parent.parent / "static" self._init_routes() self._clients: Set[web.WebSocketResponse] = set() self._lock = asyncio.Lock() @@ -48,8 +57,48 @@ class WebServer: def _init_routes(self): """Initialize application routes.""" + self.app.middlewares.append(self._cors_middleware) 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 def clients(self) -> Set[web.WebSocketResponse]: """Get the set of connected clients.""" @@ -57,11 +106,17 @@ class WebServer: async def start(self): """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) await self.runner.setup() site = web.TCPSite(self.runner, self.host, self.port) 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): """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: """Send message to client, return True if successful.""" - try: - await ws.send_json(message) - return True - except Exception: - return False + if not ws.closed: + try: + await ws.send_json(message) + return True + except Exception: + return False + return False async def broadcast(self, message_type: str, data: str, **kwargs): """Broadcast a message to all connected clients.""" @@ -91,21 +148,24 @@ class WebServer: for ws in self._clients: if not ws.closed: task = asyncio.create_task(self._send_to_client(ws, message)) - tasks.append(task) + tasks.append((ws, task)) else: disconnected.add(ws) if tasks: - results = await asyncio.gather(*tasks, return_exceptions=True) - for ws, success in zip(self._clients - disconnected, results): - if not success: + for ws, task in tasks: + try: + success = await task + if not success: + disconnected.add(ws) + except Exception: disconnected.add(ws) self._clients -= disconnected async def _handle_websocket(self, request: web.Request) -> web.WebSocketResponse: """Handle new WebSocket connections and messages.""" - ws = web.WebSocketResponse() + ws = web.WebSocketResponse(heartbeat=30) await ws.prepare(request) async with self._lock: @@ -161,7 +221,7 @@ class WebServer: if message_type == ClientMessage.APPROVE_CONTEXT: 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: if self.agent.current_state == WebAgentState.RESPONSE_APPROVAL: @@ -188,4 +248,4 @@ class WebServer: ServerMessage.RESPONSE_UPDATE, response, validation_error=self.agent._validation_error - ) \ No newline at end of file + ) diff --git a/test.sh b/test.sh index b9bc85f..efa5ff1 100755 --- a/test.sh +++ b/test.sh @@ -6,7 +6,7 @@ set -e # Build with progress output and capture the tag TAG=$( \ docker build \ - --target test \ + --target sia-test \ . \ 2>&1 | tee /dev/tty | grep "writing image" | cut -d' ' -f4 \ ) diff --git a/test/web_server_test.py b/test/web_server_test.py index dbaf1a8..5bfd32f 100644 --- a/test/web_server_test.py +++ b/test/web_server_test.py @@ -1,23 +1,19 @@ from aiohttp import WSMsgType from aiohttp import WSMsgType -from aiohttp import web from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop +from pathlib import Path from sia.web_agent import WebAgentState -from typing import AsyncIterator, Iterator -from unittest.mock import Mock, patch +from unittest.mock import patch import asyncio import asyncio 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_io_buffer import WebIOBuffer 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 class WebServerTest(AioHTTPTestCase): @@ -25,13 +21,28 @@ class WebServerTest(AioHTTPTestCase): """Create application for testing.""" self.io_buffer = WebIOBuffer() - # Create MockWebAgent instance self.agent = MockWebAgent() + + # Create temporary directory for static files + self.temp_dir = Path(tempfile.mkdtemp()) + self.static_dir = self.temp_dir / "static" + self.static_dir.mkdir(exist_ok=True) - # Create web server - self.web_server = WebServer(self.agent, self.io_buffer, "localhost", 8080) + # 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 + 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): """Helper to wait for multiple WebSocket messages.""" messages = [] diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..0eab0ea --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +.gitignore \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..8a771b4 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +coverage +.DS_Store \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..d61a977 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + React App + + +
+ + + \ No newline at end of file diff --git a/web/jest.config.js b/web/jest.config.js new file mode 100644 index 0000000..b711267 --- /dev/null +++ b/web/jest.config.js @@ -0,0 +1,10 @@ +export default { + testEnvironment: 'jsdom', + transform: { + '^.+\\.(js|jsx)$': 'babel-jest', + }, + moduleNameMapper: { + '\\.(css|less|scss|sass)$': 'identity-obj-proxy', + }, + setupFilesAfterEnv: ['/src/setupTests.js'], +} \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5b447e4 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx new file mode 100644 index 0000000..1ea5e7a --- /dev/null +++ b/web/src/components/App.jsx @@ -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 ( +
+

React App

+ +

Result: {result}

+
+ ) +} + +export default App \ No newline at end of file diff --git a/web/src/components/App.test.jsx b/web/src/components/App.test.jsx new file mode 100644 index 0000000..eb632bc --- /dev/null +++ b/web/src/components/App.test.jsx @@ -0,0 +1,15 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import App from './App' + +describe('App', () => { + it('renders without crashing', () => { + render() + expect(screen.getByText(/React App/i)).toBeInTheDocument() + }) + + it('calculates correctly when button is clicked', () => { + render() + fireEvent.click(screen.getByText(/Calculate/i)) + expect(screen.getByText(/Result: 8/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/index.jsx b/web/src/index.jsx new file mode 100644 index 0000000..563acd8 --- /dev/null +++ b/web/src/index.jsx @@ -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( + + + +) \ No newline at end of file diff --git a/web/src/utils/calculator.js b/web/src/utils/calculator.js new file mode 100644 index 0000000..9464067 --- /dev/null +++ b/web/src/utils/calculator.js @@ -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 +} \ No newline at end of file diff --git a/web/src/utils/calculator.test.js b/web/src/utils/calculator.test.js new file mode 100644 index 0000000..3192f2a --- /dev/null +++ b/web/src/utils/calculator.test.js @@ -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') + }) + }) +}) \ No newline at end of file diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..048bfae --- /dev/null +++ b/web/vite.config.js @@ -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: '/' + } +})