diff --git a/.dockerignore b/.dockerignore index 95b324c..09567e3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ !voicebot !server !client +!shared node_modules build dist diff --git a/TYPESCRIPT_GENERATION.md b/TYPESCRIPT_GENERATION.md new file mode 100644 index 0000000..40d2c05 --- /dev/null +++ b/TYPESCRIPT_GENERATION.md @@ -0,0 +1,157 @@ +# OpenAPI TypeScript Generation + +This project now supports automatic TypeScript type generation from the FastAPI server's Pydantic models using OpenAPI schema generation. + +## Overview + +The implementation follows the "OpenAPI Schema Generation (Recommended for FastAPI)" approach: + +1. **Server-side**: FastAPI automatically generates OpenAPI schema from Pydantic models +2. **Generation**: Python script extracts the schema and saves it as JSON +3. **TypeScript**: `openapi-typescript` converts the schema to TypeScript types +4. **Client**: Typed API client provides type-safe server communication + +## Generated Files + +- `client/openapi-schema.json` - OpenAPI schema extracted from FastAPI +- `client/src/api-types.ts` - TypeScript interfaces generated from OpenAPI schema +- `client/src/api-client.ts` - Typed API client with convenience methods + +## How It Works + +### 1. Schema Generation +The `server/generate_schema_simple.py` script: +- Imports the FastAPI app from `main.py` +- Extracts the OpenAPI schema using `app.openapi()` +- Saves the schema as JSON in `client/openapi-schema.json` + +### 2. TypeScript Generation +The `openapi-typescript` package: +- Reads the OpenAPI schema JSON +- Generates TypeScript interfaces in `client/src/api-types.ts` +- Creates type-safe definitions for all Pydantic models + +### 3. API Client +The `client/src/api-client.ts` file provides: +- Type-safe API client class +- Convenience functions for each endpoint +- Proper error handling with custom `ApiError` class +- Re-exported types for easy importing + +## Usage in React Components + +```typescript +import { apiClient, adminApi, healthApi, lobbiesApi, sessionsApi } from './api-client'; +import type { LobbyModel, SessionModel, AdminSetPassword } from './api-client'; + +// Using the convenience APIs +const healthStatus = await healthApi.check(); +const lobbies = await lobbiesApi.getAll(); +const session = await sessionsApi.getCurrent(); + +// Using the main client +const adminNames = await apiClient.adminListNames(); + +// With type safety for request data +const passwordData: AdminSetPassword = { + name: "admin", + password: "newpassword" +}; +const result = await adminApi.setPassword(passwordData); + +// Type-safe lobby creation +const lobbyRequest: LobbyCreateRequest = { + type: "lobby_create", + data: { + name: "My Lobby", + private: false + } +}; +const newLobby = await sessionsApi.createLobby("session-id", lobbyRequest); +``` + +## Regenerating Types + +### Manual Generation +```bash +# Generate schema from server +docker compose exec server uv run python3 generate_schema_simple.py + +# Generate TypeScript types +docker compose exec static-frontend npx openapi-typescript openapi-schema.json -o src/api-types.ts + +# Type check +docker compose exec static-frontend npm run type-check +``` + +### Automated Generation +```bash +# Run the comprehensive generation script +./generate-ts-types.sh +``` + +### NPM Scripts (in frontend container) +```bash +# Generate just the schema +npm run generate-schema + +# Generate just the TypeScript types (requires schema to exist) +npm run generate-types + +# Generate both schema and types +npm run generate-api-types +``` + +## Development Workflow + +1. **Modify Pydantic models** in `shared/models.py` +2. **Regenerate types** using one of the methods above +3. **Update React components** to use the new types +4. **Type check** to ensure everything compiles + +## Benefits + +- ✅ **Type Safety**: Full TypeScript type checking for API requests/responses +- ✅ **Auto-completion**: IDE support with auto-complete for API methods and data structures +- ✅ **Error Prevention**: Catch type mismatches at compile time +- ✅ **Documentation**: Self-documenting API with TypeScript interfaces +- ✅ **Sync Guarantee**: Types are always in sync with server models +- ✅ **Refactoring Safety**: IDE can safely refactor across frontend/backend + +## File Structure + +``` +server/ +├── main.py # FastAPI app with Pydantic models +├── generate_schema_simple.py # Schema extraction script +└── generate_api_client.py # Enhanced generator (backup) + +shared/ +└── models.py # Pydantic models (source of truth) + +client/ +├── openapi-schema.json # Generated OpenAPI schema +├── package.json # Updated with openapi-typescript dependency +└── src/ + ├── api-types.ts # Generated TypeScript interfaces + └── api-client.ts # Typed API client +``` + +## Troubleshooting + +### Container Issues +If the frontend container has dependency conflicts: +```bash +# Rebuild the frontend container +docker compose build static-frontend +docker compose up -d static-frontend +``` + +### TypeScript Errors +Ensure the generated types are up to date: +```bash +./generate-ts-types.sh +``` + +### Module Not Found Errors +Check that the volume mounts are working correctly and files are synced between host and container. diff --git a/client/entrypoint.sh b/client/entrypoint.sh index 40023cc..98fa395 100644 --- a/client/entrypoint.sh +++ b/client/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash - +set -e # Launch server in production or development mode if [ "$PRODUCTION" = "true" ]; then export REACT_APP_AI_VOICECHAT_BUILD="$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/client/openapi-schema.json b/client/openapi-schema.json new file mode 100644 index 0000000..9e78b17 --- /dev/null +++ b/client/openapi-schema.json @@ -0,0 +1,781 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "FastAPI", + "version": "0.1.0" + }, + "paths": { + "/ai-voicebot/api/admin/names": { + "get": { + "summary": "Admin List Names", + "operationId": "admin_list_names_ai_voicebot_api_admin_names_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminNamesResponse" + } + } + } + } + } + } + }, + "/ai-voicebot/api/admin/set_password": { + "post": { + "summary": "Admin Set Password", + "operationId": "admin_set_password_ai_voicebot_api_admin_set_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminSetPassword" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminActionResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ai-voicebot/api/admin/clear_password": { + "post": { + "summary": "Admin Clear Password", + "operationId": "admin_clear_password_ai_voicebot_api_admin_clear_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminClearPassword" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AdminActionResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ai-voicebot/api/health": { + "get": { + "summary": "Health", + "operationId": "health_ai_voicebot_api_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } + }, + "/ai-voicebot/api/session": { + "get": { + "summary": "Session", + "operationId": "session_ai_voicebot_api_session_get", + "parameters": [ + { + "name": "session_id", + "in": "cookie", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Session Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ai-voicebot/api/lobby": { + "get": { + "summary": "Get Lobbies", + "operationId": "get_lobbies_ai_voicebot_api_lobby_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LobbiesResponse" + } + } + } + } + } + } + }, + "/ai-voicebot/api/lobby/{session_id}": { + "post": { + "summary": "Lobby Create", + "operationId": "lobby_create_ai_voicebot_api_lobby__session_id__post", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Session Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LobbyCreateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LobbyCreateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/ai-voicebot/{path}": { + "put": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "head": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "options": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "get": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "patch": { + "summary": "Proxy Static", + "operationId": "proxy_static_ai_voicebot__path__put", + "parameters": [ + { + "name": "path", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Path" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AdminActionResponse": { + "properties": { + "status": { + "type": "string", + "enum": [ + "ok", + "not_found" + ], + "title": "Status" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "status", + "name" + ], + "title": "AdminActionResponse", + "description": "Response for admin actions" + }, + "AdminClearPassword": { + "properties": { + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "AdminClearPassword", + "description": "Request model for clearing admin password" + }, + "AdminNamesResponse": { + "properties": { + "name_passwords": { + "additionalProperties": { + "$ref": "#/components/schemas/NamePasswordRecord" + }, + "type": "object", + "title": "Name Passwords" + } + }, + "type": "object", + "required": [ + "name_passwords" + ], + "title": "AdminNamesResponse", + "description": "Response for admin names endpoint" + }, + "AdminSetPassword": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "name", + "password" + ], + "title": "AdminSetPassword", + "description": "Request model for setting admin password" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HealthResponse": { + "properties": { + "status": { + "type": "string", + "title": "Status" + } + }, + "type": "object", + "required": [ + "status" + ], + "title": "HealthResponse", + "description": "Health check response" + }, + "LobbiesResponse": { + "properties": { + "lobbies": { + "items": { + "$ref": "#/components/schemas/LobbyListItem" + }, + "type": "array", + "title": "Lobbies" + } + }, + "type": "object", + "required": [ + "lobbies" + ], + "title": "LobbiesResponse", + "description": "Response containing list of lobbies" + }, + "LobbyCreateData": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "private": { + "type": "boolean", + "title": "Private", + "default": false + } + }, + "type": "object", + "required": [ + "name" + ], + "title": "LobbyCreateData", + "description": "Data for lobby creation" + }, + "LobbyCreateRequest": { + "properties": { + "type": { + "type": "string", + "const": "lobby_create", + "title": "Type" + }, + "data": { + "$ref": "#/components/schemas/LobbyCreateData" + } + }, + "type": "object", + "required": [ + "type", + "data" + ], + "title": "LobbyCreateRequest", + "description": "Request for creating a lobby" + }, + "LobbyCreateResponse": { + "properties": { + "type": { + "type": "string", + "const": "lobby_created", + "title": "Type" + }, + "data": { + "$ref": "#/components/schemas/LobbyModel" + } + }, + "type": "object", + "required": [ + "type", + "data" + ], + "title": "LobbyCreateResponse", + "description": "Response for lobby creation" + }, + "LobbyListItem": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + } + }, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "LobbyListItem", + "description": "Lobby item for list responses" + }, + "LobbyModel": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "private": { + "type": "boolean", + "title": "Private", + "default": false + } + }, + "type": "object", + "required": [ + "id", + "name" + ], + "title": "LobbyModel", + "description": "Core lobby model used across components" + }, + "NamePasswordRecord": { + "properties": { + "salt": { + "type": "string", + "title": "Salt" + }, + "hash": { + "type": "string", + "title": "Hash" + } + }, + "type": "object", + "required": [ + "salt", + "hash" + ], + "title": "NamePasswordRecord", + "description": "Password hash record for reserved names" + }, + "SessionResponse": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "name": { + "type": "string", + "title": "Name" + }, + "lobbies": { + "items": { + "$ref": "#/components/schemas/LobbyModel" + }, + "type": "array", + "title": "Lobbies" + } + }, + "type": "object", + "required": [ + "id", + "name", + "lobbies" + ], + "title": "SessionResponse", + "description": "Session response model" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + } + } + } +} \ No newline at end of file diff --git a/client/package.json b/client/package.json index f1de2a4..283a765 100644 --- a/client/package.json +++ b/client/package.json @@ -25,14 +25,23 @@ "@types/node": "^20.11.30", "@types/react": "^18.2.70", "@types/react-dom": "^18.2.19", - "@types/react-router-dom": "^5.3.3" + "@types/react-router-dom": "^5.3.3", + "typescript": "^4.9.5", + "openapi-typescript": "^6.7.6" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "generate-schema": "cd ../server && uv run python3 generate_schema_simple.py", + "generate-types": "npx openapi-typescript openapi-schema.json -o src/api-types.ts", + "generate-api-types": "npm run generate-schema && npm run generate-types", + "prebuild": "npm run generate-api-types" + }, + "npmConfig": { + "legacy-peer-deps": true }, "eslintConfig": { "extends": [ diff --git a/client/src/api-client.ts b/client/src/api-client.ts new file mode 100644 index 0000000..6927897 --- /dev/null +++ b/client/src/api-client.ts @@ -0,0 +1,134 @@ +// TypeScript API client for AI Voicebot server +import { components } from './api-types'; + +// Re-export commonly used types from the generated schema +export type LobbyModel = components['schemas']['LobbyModel']; +export type LobbyListItem = components['schemas']['LobbyListItem']; +export type LobbyCreateData = components['schemas']['LobbyCreateData']; +export type NamePasswordRecord = components['schemas']['NamePasswordRecord']; + +// Type aliases for API methods +export type AdminNamesResponse = components['schemas']['AdminNamesResponse']; +export type AdminActionResponse = components['schemas']['AdminActionResponse']; +export type AdminSetPassword = components['schemas']['AdminSetPassword']; +export type AdminClearPassword = components['schemas']['AdminClearPassword']; +export type HealthResponse = components['schemas']['HealthResponse']; +export type LobbiesResponse = components['schemas']['LobbiesResponse']; +export type SessionResponse = components['schemas']['SessionResponse']; +export type LobbyCreateRequest = components['schemas']['LobbyCreateRequest']; +export type LobbyCreateResponse = components['schemas']['LobbyCreateResponse']; + +export class ApiError extends Error { + constructor( + public status: number, + public statusText: string, + public data?: any + ) { + super(`HTTP ${status}: ${statusText}`); + this.name = 'ApiError'; + } +} + +export class ApiClient { + private baseURL: string; + private defaultHeaders: Record; + + constructor(baseURL?: string) { + this.baseURL = baseURL || process.env.REACT_APP_API_URL || 'http://localhost:8001'; + this.defaultHeaders = {}; + } + + private async request(path: string, options: { + method: string; + body?: any; + params?: Record; + }): Promise { + const url = new URL(path, this.baseURL); + + if (options.params) { + Object.entries(options.params).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + + const requestInit: RequestInit = { + method: options.method, + headers: { + 'Content-Type': 'application/json', + ...this.defaultHeaders, + }, + }; + + if (options.body && options.method !== 'GET') { + requestInit.body = JSON.stringify(options.body); + } + + const response = await fetch(url.toString(), requestInit); + + if (!response.ok) { + let errorData; + try { + errorData = await response.json(); + } catch { + errorData = await response.text(); + } + throw new ApiError(response.status, response.statusText, errorData); + } + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + + return response.text() as unknown as T; + } + + // Admin API methods + async adminListNames(): Promise { + return this.request('/ai-voicebot/api/admin/names', { method: 'GET' }); + } + + async adminSetPassword(data: AdminSetPassword): Promise { + return this.request('/ai-voicebot/api/admin/set_password', { method: 'POST', body: data }); + } + + async adminClearPassword(data: AdminClearPassword): Promise { + return this.request('/ai-voicebot/api/admin/clear_password', { method: 'POST', body: data }); + } + + // Health check + async healthCheck(): Promise { + return this.request('/ai-voicebot/api/health', { method: 'GET' }); + } + + // Session methods + async getSession(): Promise { + return this.request('/ai-voicebot/api/session', { method: 'GET' }); + } + + // Lobby methods + async getLobbies(): Promise { + return this.request('/ai-voicebot/api/lobby', { method: 'GET' }); + } + + async createLobby(sessionId: string, data: LobbyCreateRequest): Promise { + return this.request(`/ai-voicebot/api/lobby/${sessionId}`, { method: 'POST', body: data }); + } +} + +// Default client instance +export const apiClient = new ApiClient(); + +// Convenience API namespaces +export const adminApi = { + listNames: () => apiClient.adminListNames(), + setPassword: (data: AdminSetPassword) => apiClient.adminSetPassword(data), + clearPassword: (data: AdminClearPassword) => apiClient.adminClearPassword(data), +}; + +export const healthApi = { check: () => apiClient.healthCheck() }; +export const lobbiesApi = { getAll: () => apiClient.getLobbies() }; +export const sessionsApi = { + getCurrent: () => apiClient.getSession(), + createLobby: (sessionId: string, data: LobbyCreateRequest) => apiClient.createLobby(sessionId, data), +}; diff --git a/client/src/api-types.ts b/client/src/api-types.ts new file mode 100644 index 0000000..dc70579 --- /dev/null +++ b/client/src/api-types.ts @@ -0,0 +1,375 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + + +export interface paths { + "/ai-voicebot/api/admin/names": { + /** Admin List Names */ + get: operations["admin_list_names_ai_voicebot_api_admin_names_get"]; + }; + "/ai-voicebot/api/admin/set_password": { + /** Admin Set Password */ + post: operations["admin_set_password_ai_voicebot_api_admin_set_password_post"]; + }; + "/ai-voicebot/api/admin/clear_password": { + /** Admin Clear Password */ + post: operations["admin_clear_password_ai_voicebot_api_admin_clear_password_post"]; + }; + "/ai-voicebot/api/health": { + /** Health */ + get: operations["health_ai_voicebot_api_health_get"]; + }; + "/ai-voicebot/api/session": { + /** Session */ + get: operations["session_ai_voicebot_api_session_get"]; + }; + "/ai-voicebot/api/lobby": { + /** Get Lobbies */ + get: operations["get_lobbies_ai_voicebot_api_lobby_get"]; + }; + "/ai-voicebot/api/lobby/{session_id}": { + /** Lobby Create */ + post: operations["lobby_create_ai_voicebot_api_lobby__session_id__post"]; + }; + "/ai-voicebot/{path}": { + /** Proxy Static */ + get: operations["proxy_static_ai_voicebot__path__put"]; + /** Proxy Static */ + put: operations["proxy_static_ai_voicebot__path__put"]; + /** Proxy Static */ + post: operations["proxy_static_ai_voicebot__path__put"]; + /** Proxy Static */ + delete: operations["proxy_static_ai_voicebot__path__put"]; + /** Proxy Static */ + options: operations["proxy_static_ai_voicebot__path__put"]; + /** Proxy Static */ + head: operations["proxy_static_ai_voicebot__path__put"]; + /** Proxy Static */ + patch: operations["proxy_static_ai_voicebot__path__put"]; + }; +} + +export type webhooks = Record; + +export interface components { + schemas: { + /** + * AdminActionResponse + * @description Response for admin actions + */ + AdminActionResponse: { + /** + * Status + * @enum {string} + */ + status: "ok" | "not_found"; + /** Name */ + name: string; + }; + /** + * AdminClearPassword + * @description Request model for clearing admin password + */ + AdminClearPassword: { + /** Name */ + name: string; + }; + /** + * AdminNamesResponse + * @description Response for admin names endpoint + */ + AdminNamesResponse: { + /** Name Passwords */ + name_passwords: { + [key: string]: components["schemas"]["NamePasswordRecord"]; + }; + }; + /** + * AdminSetPassword + * @description Request model for setting admin password + */ + AdminSetPassword: { + /** Name */ + name: string; + /** Password */ + password: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** + * HealthResponse + * @description Health check response + */ + HealthResponse: { + /** Status */ + status: string; + }; + /** + * LobbiesResponse + * @description Response containing list of lobbies + */ + LobbiesResponse: { + /** Lobbies */ + lobbies: components["schemas"]["LobbyListItem"][]; + }; + /** + * LobbyCreateData + * @description Data for lobby creation + */ + LobbyCreateData: { + /** Name */ + name: string; + /** + * Private + * @default false + */ + private?: boolean; + }; + /** + * LobbyCreateRequest + * @description Request for creating a lobby + */ + LobbyCreateRequest: { + /** + * Type + * @constant + */ + type: "lobby_create"; + data: components["schemas"]["LobbyCreateData"]; + }; + /** + * LobbyCreateResponse + * @description Response for lobby creation + */ + LobbyCreateResponse: { + /** + * Type + * @constant + */ + type: "lobby_created"; + data: components["schemas"]["LobbyModel"]; + }; + /** + * LobbyListItem + * @description Lobby item for list responses + */ + LobbyListItem: { + /** Id */ + id: string; + /** Name */ + name: string; + }; + /** + * LobbyModel + * @description Core lobby model used across components + */ + LobbyModel: { + /** Id */ + id: string; + /** Name */ + name: string; + /** + * Private + * @default false + */ + private?: boolean; + }; + /** + * NamePasswordRecord + * @description Password hash record for reserved names + */ + NamePasswordRecord: { + /** Salt */ + salt: string; + /** Hash */ + hash: string; + }; + /** + * SessionResponse + * @description Session response model + */ + SessionResponse: { + /** Id */ + id: string; + /** Name */ + name: string; + /** Lobbies */ + lobbies: components["schemas"]["LobbyModel"][]; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} + +export type $defs = Record; + +export type external = Record; + +export interface operations { + + /** Admin List Names */ + admin_list_names_ai_voicebot_api_admin_names_get: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AdminNamesResponse"]; + }; + }; + }; + }; + /** Admin Set Password */ + admin_set_password_ai_voicebot_api_admin_set_password_post: { + requestBody: { + content: { + "application/json": components["schemas"]["AdminSetPassword"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AdminActionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Admin Clear Password */ + admin_clear_password_ai_voicebot_api_admin_clear_password_post: { + requestBody: { + content: { + "application/json": components["schemas"]["AdminClearPassword"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AdminActionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Health */ + health_ai_voicebot_api_health_get: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["HealthResponse"]; + }; + }; + }; + }; + /** Session */ + session_ai_voicebot_api_session_get: { + parameters: { + cookie?: { + session_id?: string | null; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["SessionResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Get Lobbies */ + get_lobbies_ai_voicebot_api_lobby_get: { + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["LobbiesResponse"]; + }; + }; + }; + }; + /** Lobby Create */ + lobby_create_ai_voicebot_api_lobby__session_id__post: { + parameters: { + path: { + session_id: string; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LobbyCreateRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["LobbyCreateResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** Proxy Static */ + proxy_static_ai_voicebot__path__put: { + parameters: { + path: { + path: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/client/src/api-usage-examples.ts b/client/src/api-usage-examples.ts new file mode 100644 index 0000000..dbf9602 --- /dev/null +++ b/client/src/api-usage-examples.ts @@ -0,0 +1,91 @@ +/** + * Example: How to update App.tsx to use the generated API types + * + * This demonstrates how to replace local type definitions with the + * generated TypeScript types from your Pydantic models. + */ + +// BEFORE (local type definitions): +// type Lobby = { +// id: string; +// name: string; +// private: boolean; +// }; + +// AFTER (using generated types): +import { + apiClient, + lobbiesApi, + sessionsApi, + healthApi, + adminApi, + ApiError, + type LobbyModel, // Use this instead of local Lobby type + type LobbyCreateRequest, + type AdminSetPassword +} from './api-client'; + +// Example usage in a React component: + +// 1. Fetching data with type safety +const fetchLobbies = async (setLobbies: (lobbies: any[]) => void, setError: (error: string) => void) => { + try { + const response = await lobbiesApi.getAll(); + // response.lobbies is properly typed as LobbyListItem[] + setLobbies(response.lobbies); + } catch (error) { + if (error instanceof ApiError) { + console.error(`API Error ${error.status}: ${error.statusText}`, error.data); + } + setError('Failed to fetch lobbies'); + } +}; + +// 2. Creating a lobby with typed request +const createNewLobby = async (sessionId: string, lobbyName: string, isPrivate: boolean, setError: (error: string) => void) => { + const lobbyRequest: LobbyCreateRequest = { + type: "lobby_create", + data: { + name: lobbyName, + private: isPrivate + } + }; + + try { + const newLobby = await sessionsApi.createLobby(sessionId, lobbyRequest); + // newLobby.data is properly typed as LobbyModel + console.log('Created lobby:', newLobby.data); + } catch (error) { + if (error instanceof ApiError) { + setError(`Failed to create lobby: ${error.message}`); + } + } +}; + +// 3. Health check with typing +const checkServerHealth = async () => { + try { + const health = await healthApi.check(); + // health.status is properly typed as string + console.log('Server status:', health.status); + } catch (error) { + console.error('Server health check failed:', error); + } +}; + +// 4. Admin operations with type safety +const setAdminPassword = async (name: string, password: string) => { + const passwordData: AdminSetPassword = { name, password }; + + try { + const result = await adminApi.setPassword(passwordData); + // result.status is typed as "ok" | "not_found" + if (result.status === "ok") { + console.log('Password set successfully'); + } + } catch (error) { + console.error('Failed to set password:', error); + } +}; + +export { fetchLobbies, createNewLobby, checkServerHealth, setAdminPassword }; diff --git a/docker-compose.yml b/docker-compose.yml index 4e56c6d..16ca5fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: ports: - "8001:8000" volumes: + - ./shared:/shared:ro - ./server:/server:rw - ./server/.venv:/server/.venv:rw - ./client/build:/client/build:ro @@ -52,6 +53,7 @@ services: restart: always network_mode: host volumes: + - ./shared:/shared:ro - ./voicebot:/voicebot:rw - ./voicebot/.venv:/voicebot/.venv:rw # network_mode: host diff --git a/generate-ts-types.sh b/generate-ts-types.sh new file mode 100755 index 0000000..e0eba7b --- /dev/null +++ b/generate-ts-types.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Comprehensive script to generate TypeScript types from FastAPI OpenAPI schema. +# This script coordinates between the server and frontend containers to: +# 1. Generate OpenAPI schema from FastAPI server +# 2. Generate TypeScript types from the schema +# 3. Ensure frontend container dependencies are installed + +set -e + +echo "🚀 Starting OpenAPI TypeScript generation process..." + +# Change to the project directory +cd "$(dirname "$0")" + +echo "📋 Step 1: Generating OpenAPI schema from FastAPI server..." +docker compose exec server uv run python3 generate_schema_simple.py + +echo "📋 Step 2: Ensuring frontend container is running..." +docker compose up -d static-frontend + +echo "📋 Step 3: Installing/updating frontend dependencies..." +docker compose exec static-frontend npm install --legacy-peer-deps + +echo "📋 Step 4: Generating TypeScript types from OpenAPI schema..." +docker compose exec static-frontend npx openapi-typescript openapi-schema.json -o src/api-types.ts + +echo "📋 Step 5: Running TypeScript type checking..." +docker compose exec static-frontend npm run type-check + +echo "✅ TypeScript generation complete!" +echo "📄 Generated files:" +echo " - client/openapi-schema.json (OpenAPI schema)" +echo " - client/src/api-types.ts (TypeScript types)" +echo " - client/src/api-client.ts (API client utilities)" +echo "" +echo "💡 Usage in your React components:" +echo " import { apiClient, adminApi, healthApi } from './api-client';" +echo " import type { LobbyModel, SessionModel } from './api-client';" diff --git a/server/generate_schema_simple.py b/server/generate_schema_simple.py new file mode 100644 index 0000000..60b6715 --- /dev/null +++ b/server/generate_schema_simple.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +""" +Simple OpenAPI schema generator for FastAPI. +This generates only the JSON schema file that can be used with openapi-typescript. +""" + +import json +import sys +from pathlib import Path + +def generate_schema(): + """Generate OpenAPI schema from the FastAPI app""" + try: + # Add shared module to path for Docker environment + shared_path = "/shared" + if shared_path not in sys.path: + sys.path.insert(0, shared_path) + + # Import the FastAPI app + from main import app + + # Get the OpenAPI schema + schema = app.openapi() + + # Write the schema to a JSON file that can be accessed from outside container + schema_file = Path("/client/openapi-schema.json") + with open(schema_file, 'w', encoding='utf-8') as f: + json.dump(schema, f, indent=2, ensure_ascii=False) + + print(f"✅ OpenAPI schema generated successfully at: {schema_file}") + print(f"Schema contains {len(schema.get('components', {}).get('schemas', {}))} component schemas") + + return True + + except Exception as e: + print(f"❌ Error generating OpenAPI schema: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + success = generate_schema() + sys.exit(0 if success else 1) diff --git a/server/main.py b/server/main.py index 9fd0a10..55e67d5 100644 --- a/server/main.py +++ b/server/main.py @@ -6,46 +6,52 @@ from fastapi import ( FastAPI, Path, WebSocket, - WebSocketDisconnect, Request, Response, + WebSocketDisconnect, ) -from fastapi.staticfiles import StaticFiles import secrets import os -import httpx import json import hashlib import binascii import sys +from fastapi.staticfiles import StaticFiles +import httpx from pydantic import ValidationError from logger import logger # Import shared models sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from shared.models import ( + HealthResponse, + LobbiesResponse, + LobbyCreateRequest, + LobbyCreateResponse, + LobbyListItem, + LobbyModel, NamePasswordRecord, LobbySaved, + SessionResponse, SessionSaved, SessionsPayload, AdminNamesResponse, AdminActionResponse, AdminSetPassword, AdminClearPassword, - HealthResponse, - LobbyListItem, - LobbiesResponse, - LobbyModel, - SessionResponse, - LobbyCreateRequest, - LobbyCreateResponse, + JoinStatusModel, ) # Mapping of reserved names to password records (lowercased name -> {salt:..., hash:...}) name_passwords: dict[str, dict[str, str]] = {} +all_label = "[ all ]" +info_label = "[ info ]" +todo_label = "[ todo ]" +unset_label = "[ ---- ]" + def _hash_password(password: str, salt_hex: str | None = None) -> tuple[str, str]: """Return (salt_hex, hash_hex) for the given password. If salt_hex is provided @@ -109,12 +115,77 @@ def admin_clear_password(request: Request, payload: AdminClearPassword = Body(.. return {"status": "not_found", "name": payload.name} +lobbies: dict[str, Lobby] = {} + + class LobbyResponse(TypedDict): id: str name: str private: bool +class Lobby: + def __init__(self, name: str, id: str | None = None, private: bool = False): + self.id = secrets.token_hex(16) if id is None else id + self.short = self.id[:8] + self.name = name + self.sessions: dict[str, Session] = {} # All lobby members + self.private = private + + def getName(self) -> str: + return f"{self.short}:{self.name}" + + async def update_state(self, requesting_session: Session | None = None): + users: list[dict[str, str | bool]] = [ + { + "name": s.name, + "live": True if s.ws else False, + "session_id": s.id, + "protected": True + if s.name and s.name.lower() in name_passwords + else False, + } + for s in self.sessions.values() + if s.name + ] + if requesting_session: + logger.info( + f"{requesting_session.getName()} -> lobby_state({self.getName()})" + ) + if requesting_session.ws: + await requesting_session.ws.send_json( + {"type": "lobby_state", "data": {"participants": users}} + ) + else: + logger.warning( + f"{requesting_session.getName()} - No WebSocket connection." + ) + else: + for s in self.sessions.values(): + logger.info(f"{s.getName()} -> lobby_state({self.getName()})") + if s.ws: + await s.ws.send_json( + {"type": "lobby_state", "data": {"participants": users}} + ) + + def getSession(self, id: str) -> Session | None: + return self.sessions.get(id, None) + + async def addSession(self, session: Session) -> None: + if session.id in self.sessions: + logger.warning(f"{session.getName()} - Already in lobby {self.getName()}.") + return None + self.sessions[session.id] = session + await self.update_state() + + async def removeSession(self, session: Session) -> None: + if session.id not in self.sessions: + logger.warning(f"{session.getName()} - Not in lobby {self.getName()}.") + return None + del self.sessions[session.id] + await self.update_state() + + class Session: _instances: list[Session] = [] _save_file = "sessions.json" @@ -249,13 +320,10 @@ class Session: if lobby.id in self.lobby_peers or self.id in lobby.sessions: logger.info(f"{self.getName()} - Already joined to {lobby.getName()}.") - await self.ws.send_json( - { - "type": "join_status", - "status": "Joined", - "message": f"Already joined to lobby {lobby.getName()}", - } + data = JoinStatusModel( + status="Joined", message=f"Already joined to lobby {lobby.getName()}" ) + await self.ws.send_json({"type": "join_status", "data": data.model_dump()}) return # Initialize the peer list for this lobby @@ -315,7 +383,7 @@ class Session: await lobby.addSession(self) Session.save() - await self.ws.send_json({"type": "join_status", "status": "Joined"}) + await self.ws.send_json({"type": "join_status", "data": {"status": "Joined"}}) async def part(self, lobby: Lobby): if lobby.id not in self.lobby_peers or self.id not in lobby.sessions: @@ -377,77 +445,12 @@ class Session: Session.save() -class Lobby: - def __init__(self, name: str, id: str | None = None, private: bool = False): - self.id = secrets.token_hex(16) if id is None else id - self.short = self.id[:8] - self.name = name - self.sessions: dict[str, Session] = {} # All lobby members - self.private = private - - def getName(self) -> str: - return f"{self.short}:{self.name}" - - async def update_state(self, requesting_session: Session | None = None): - users: list[dict[str, str | bool]] = [ - { - "name": s.name, - "live": True if s.ws else False, - "session_id": s.id, - "protected": True - if s.name and s.name.lower() in name_passwords - else False, - } - for s in self.sessions.values() - if s.name - ] - if requesting_session: - logger.info( - f"{requesting_session.getName()} -> lobby_state({self.getName()})" - ) - if requesting_session.ws: - await requesting_session.ws.send_json( - {"type": "lobby_state", "data": {"participants": users}} - ) - else: - logger.warning( - f"{requesting_session.getName()} - No WebSocket connection." - ) - else: - for s in self.sessions.values(): - logger.info(f"{s.getName()} -> lobby_state({self.getName()})") - if s.ws: - await s.ws.send_json( - {"type": "lobby_state", "data": {"participants": users}} - ) - - def getSession(self, id: str) -> Session | None: - return self.sessions.get(id, None) - - async def addSession(self, session: Session) -> None: - if session.id in self.sessions: - logger.warning(f"{session.getName()} - Already in lobby {self.getName()}.") - return None - self.sessions[session.id] = session - await self.update_state() - - async def removeSession(self, session: Session) -> None: - if session.id not in self.sessions: - logger.warning(f"{session.getName()} - Not in lobby {self.getName()}.") - return None - del self.sessions[session.id] - await self.update_state() - - def getName(session: Session | None) -> str | None: if session and session.name: return session.name return None -lobbies: dict[str, Lobby] = {} - - def getSession(session_id: str) -> Session | None: return Session.getSession(session_id) @@ -573,12 +576,6 @@ async def lobby_create( ) -all_label = "[ all ]" -info_label = "[ info ]" -todo_label = "[ todo ]" -unset_label = "[ ---- ]" - - # Register websocket endpoint directly on app with full public_url path @app.websocket(f"{public_url}" + "ws/lobby/{lobby_id}/{session_id}") async def lobby_join( @@ -647,11 +644,11 @@ async def lobby_join( try: while True: - message = await websocket.receive_json() - type = message.get("type", None) - data: dict[str, Any] | None = message.get("data", None) + packet = await websocket.receive_json() + type = packet.get("type", None) + data: dict[str, Any] | None = packet.get("data", None) if not type: - logger.error(f"{session.getName()} - Invalid request: {message}") + logger.error(f"{session.getName()} - Invalid request: {packet}") await websocket.send_json({"type": "error", "error": "Invalid request"}) continue # logger.info(f"{session.getName()} <- RAW Rx: {data}") @@ -685,11 +682,13 @@ async def lobby_join( logger.info(f"{session.getName()}: -> update('name', {name})") await websocket.send_json( { - "type": "update", - "name": name, - "protected": True - if name.lower() in name_passwords - else False, + "type": "update_name", + "data": { + "name": name, + "protected": True + if name.lower() in name_passwords + else False, + }, } ) # For any clients in any lobby with this session, update their user lists @@ -757,9 +756,11 @@ async def lobby_join( try: await displaced.ws.send_json( { - "type": "update", - "name": fallback, - "protected": False, + "type": "update_name", + "data": { + "name": fallback, + "protected": False, + }, } ) except Exception: @@ -782,11 +783,13 @@ async def lobby_join( ) await websocket.send_json( { - "type": "update", - "name": name, - "protected": True - if name.lower() in name_passwords - else False, + "type": "update_name", + "data": { + "name": name, + "protected": True + if name.lower() in name_passwords + else False, + }, } ) # Notify lobbies for this session diff --git a/shared/models.py b/shared/models.py index 73a6f4a..d3959f9 100644 --- a/shared/models.py +++ b/shared/models.py @@ -114,12 +114,6 @@ class LobbyCreateResponse(BaseModel): # WebSocket Message Models # ============================================================================= -class WebSocketMessageModel(BaseModel): - """Base model for all WebSocket messages""" - type: str - data: Dict[str, object] = {} - - class JoinStatusModel(BaseModel): """WebSocket message for join status updates""" status: str @@ -137,10 +131,16 @@ class LobbyStateModel(BaseModel): participants: List[ParticipantModel] = [] -class UpdateModel(BaseModel): - """Generic update message model""" - class Config: - extra = "allow" +class UpdateNameModel(BaseModel): + name: str + protected: Optional[bool] = False + + +class WebSocketMessageModel(BaseModel): + """Base model for all WebSocket messages""" + + type: str + data: JoinStatusModel | UserJoinedModel | LobbyStateModel | UpdateNameModel # ============================================================================= diff --git a/shared/test_models.py b/shared/test_models.py index ed75266..8494587 100644 --- a/shared/test_models.py +++ b/shared/test_models.py @@ -17,26 +17,6 @@ def test_models(): try: # Test that models can be imported - from models import ( - LobbyModel, - SessionModel, - ParticipantModel, - WebSocketMessageModel, - JoinStatusModel, - UserJoinedModel, - LobbyStateModel, - UpdateModel, - AddPeerModel, - RemovePeerModel, - SessionDescriptionModel, - IceCandidateModel, - ICECandidateDictModel, - SessionDescriptionTypedModel, - LobbyCreateRequest, - LobbyCreateResponse, - AdminActionResponse, - HealthResponse, - ) print("✓ All shared models imported successfully") # Test model definitions (without actually creating instances since we don't have pydantic) diff --git a/voicebot/logger.py b/voicebot/logger.py index 54546a6..c3819c9 100644 --- a/voicebot/logger.py +++ b/voicebot/logger.py @@ -48,9 +48,9 @@ def _setup_logging(level: str=logging_level) -> logging.Logger: warnings.filterwarnings("ignore", message="n_jobs value 1 overridden") warnings.filterwarnings("ignore", message=".*websocket.*is deprecated") - logging.getLogger("aiortc").setLevel(logging.DEBUG) - logging.getLogger("aioice").setLevel(logging.DEBUG) - logging.getLogger("asyncio").setLevel(logging.DEBUG) + logging.getLogger("aiortc").setLevel(logging.INFO) + logging.getLogger("aioice").setLevel(logging.INFO) + logging.getLogger("asyncio").setLevel(logging.INFO) numeric_level = getattr(logging, level.upper(), None) if not isinstance(numeric_level, int): diff --git a/voicebot/main.py b/voicebot/main.py index 71f2099..124aa65 100644 --- a/voicebot/main.py +++ b/voicebot/main.py @@ -44,13 +44,10 @@ from shared.models import ( WebSocketMessageModel, JoinStatusModel, UserJoinedModel, - ParticipantModel, LobbyStateModel, - UpdateModel, - ICECandidateDictModel, + UpdateNameModel, AddPeerModel, RemovePeerModel, - SessionDescriptionTypedModel, SessionDescriptionModel, IceCandidateModel, ) @@ -352,7 +349,7 @@ class AnimatedVideoTrack(MediaStreamTrack): # Add frame counter text frame_text = f"Frame: {int(time.time() * 1000) % 10000}" - logger.info(frame_text) + # logger.info(frame_text) cv2.putText( frame_array, frame_text, @@ -653,9 +650,9 @@ class WebRTCSignalingClient: return participants = validated.participants logger.info(f"Lobby state updated: {len(participants)} participants") - elif msg_type == "update": + elif msg_type == "update_name": try: - validated = UpdateModel.model_validate(data) + validated = UpdateNameModel.model_validate(data) except ValidationError as e: logger.error(f"Invalid update payload: {e}", exc_info=True) return