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/
model/
claude.txt

View File

@@ -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"]

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 \
--gpus=all \
--privileged \
-p 8080:8080 \
-v /$(pwd)/model/:/root/model/ \
$TAG

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 \
)

View File

@@ -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 web server
self.web_server = WebServer(self.agent, self.io_buffer, "localhost", 8080)
# 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 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 = []

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: '/'
}
})