commit 912b205699cf5613e3d7b98cf9e67c7a8054206f Author: Aodhan Collins Date: Sun Aug 17 19:31:33 2025 +0100 Initial commit. Basic docker deployment with Local LLM integration and simple game state. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2ddda98 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +PORTAINER_URL=http://10.0.0.199:9000 +PORTAINER_USERNAME=yourusername +PORTAINER_PASSWORD=yourpassword +PORTAINER_ENDPOINT=yourendpoint # Optional \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d56a93 --- /dev/null +++ b/.gitignore @@ -0,0 +1,66 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so +*.pyd +*.pyo + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Packaging +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Testing and coverage +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +.pytest_cache/ +.cache/ +.mypy_cache/ +.pytype/ +.ruff_cache/ +.pyre/ + +# Editor and OS files +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp +*.swo + +# Environment variables +.env +.env.* +!.env.example + +# Logs and temp +logs/ +*.log +tmp/ +temp/ + +# Application data (runtime) +data/sessions/ + +# Node (if ever used for frontends) +node_modules/ + +# pyenv +.python-version + +# Jupyter +.ipynb_checkpoints/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..544449b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Use Python 3.9 slim image as base +FROM python:3.9-slim + +# Set working directory +WORKDIR /app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Install system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements file +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create a non-root user +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app +USER app + +# Expose port for web interface +EXPOSE 8000 + +# Command to run the FastAPI web app +CMD ["uvicorn", "web.app:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..696dc3e --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Text-Based LLM Interaction System + +A Python-based system for interacting with an LLM running on LM Studio through a text-based interface. + +## Setup Guide + +For detailed instructions on setting up pyenv and virtual environments, see [setup_guide.md](setup_guide.md). + +## Project Structure + +``` +text_adventure/ +├── main.py # Entry point +├── config.py # Configuration settings +├── llm_client.py # LLM communication +├── interface.py # Text input/output +├── conversation.py # Conversation history management +├── test_interface.py # Test script for interface +└── README.md # This file +``` + +## Setup + +### Option 1: Traditional Setup +1. Ensure you have Python 3.6+ installed +2. Install required dependencies: + ```bash + pip install -r requirements.txt + ``` +3. Make sure LM Studio is running on 10.0.0.200:1234 + +### Option 2: Docker Setup (Recommended) +1. Install Docker and Docker Compose +2. Build and run the application: + ```bash + docker-compose up --build + ``` +3. Make sure LM Studio is running on 10.0.0.200:1234 and accessible from the Docker container + +### Option 3: Portainer Deployment +1. Ensure you have a Portainer instance running at 10.0.0.199:9000 +2. Configure your `.env` file with Portainer credentials (see `.env.example`) +3. Run the deployment script: + ```bash + python deploy_to_portainer.py + ``` +4. Or set environment variables and run: + ```bash + export PORTAINER_URL=http://10.0.0.199:9000 + export PORTAINER_USERNAME=admin + export PORTAINER_PASSWORD=yourpassword + python deploy_to_portainer.py + ``` + +## Usage + +To run the main application: +```bash +python main.py +``` + +## Testing + +To test the complete system: +```bash +python test_system.py +``` + +To test just the interface: +```bash +python test_interface.py +``` + +To test connection to LM Studio: +```bash +python test_llm_connection.py +``` + +To test message exchange with LLM: +```bash +python test_llm_exchange.py +``` + +## Configuration + +The system is configured to connect to LM Studio at `http://10.0.0.200:1234`. You can modify the `config.py` file to change this setting. + +## Components + +### Main Application (main.py) +The entry point that ties all components together. + +### Configuration (config.py) +Contains settings for connecting to LM Studio. + +### LLM Client (llm_client.py) +Handles communication with the LM Studio API. + +### Interface (interface.py) +Manages text input and output with the user. + +### Conversation Manager (conversation.py) +Keeps track of the conversation history between user and LLM. + +## Testing + +Run the test_interface.py script to verify the text input/output functionality works correctly. \ No newline at end of file diff --git a/architecture.md b/architecture.md new file mode 100644 index 0000000..07aab51 --- /dev/null +++ b/architecture.md @@ -0,0 +1,89 @@ +# Text-Based LLM Interaction System Architecture + +## Overview +This document outlines the architecture for a text-based system that allows users to interact with an LLM running on LM Studio. + +## Components + +### 1. User Interface Layer +- **Text Input Handler**: Captures user input from terminal +- **Text Output Display**: Shows LLM responses to user +- **Session Manager**: Manages conversation history + +### 2. Communication Layer +- **LLM Client**: Handles HTTP communication with LM Studio +- **API Interface**: Formats requests/responses according to LM Studio's API + +### 3. Core Logic Layer +- **Message Processor**: Processes user input and LLM responses +- **Conversation History**: Maintains context between messages + +## Data Flow + +```mermaid +graph TD + A[User] --> B[Text Input Handler] + B --> C[Message Processor] + C --> D[LLM Client] + D --> E[LM Studio Server] + E --> D + D --> C + C --> F[Text Output Display] + F --> A +``` + +## Technical Details + +### LM Studio API +- Endpoint: http://10.0.0.200:1234/v1/chat/completions +- Method: POST +- Content-Type: application/json + +### Request Format +```json +{ + "model": "model_name", + "messages": [ + {"role": "user", "content": "user message"} + ], + "temperature": 0.7, + "max_tokens": -1 +} +``` + +### Response Format +```json +{ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1677652288, + "model": "model_name", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "response message" + }, + "finish_reason": "stop" + } + ] +} +``` + +## Project Structure +``` +text_adventure/ +├── main.py # Entry point +├── config.py # Configuration settings +├── llm_client.py # LLM communication +├── interface.py # Text input/output +└── conversation.py # Conversation history management +``` + +## Implementation Plan +1. Create basic text input/output interface +2. Implement LLM client for LM Studio communication +3. Add conversation history management +4. Integrate components +5. Test functionality \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..31ba2dd --- /dev/null +++ b/config.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +""" +Configuration settings for the text-based LLM interaction system. +""" + + +class Config: + """Configuration class for the LLM interaction system.""" + + def __init__(self): + """Initialize configuration settings.""" + # LM Studio server settings + self.LM_STUDIO_HOST = "10.0.0.200" + self.LM_STUDIO_PORT = 1234 + self.API_BASE_URL = f"http://{self.LM_STUDIO_HOST}:{self.LM_STUDIO_PORT}/v1" + self.CHAT_COMPLETIONS_ENDPOINT = f"{self.API_BASE_URL}/chat/completions" + + # Default model settings + self.DEFAULT_MODEL = "default_model" # Will be updated based on available models + self.TEMPERATURE = 0.7 + self.MAX_TOKENS = -1 # -1 means no limit + + # Request settings + self.REQUEST_TIMEOUT = 30 # seconds + + def get_api_url(self): + """Get the base API URL for LM Studio.""" + return self.API_BASE_URL + + def get_chat_completions_url(self): + """Get the chat completions endpoint URL.""" + return self.CHAT_COMPLETIONS_ENDPOINT \ No newline at end of file diff --git a/config/game_config.yaml b/config/game_config.yaml new file mode 100644 index 0000000..ec8e776 --- /dev/null +++ b/config/game_config.yaml @@ -0,0 +1,68 @@ +# Game LLM behavior configuration (Locked Room Test) + +system_prompt: | + You are the NARRATOR for a deterministic text adventure. A separate ENGINE + controls all world state and outcomes. Your job is to vividly describe only + what the ENGINE decided. + + If you receive ENGINE CONTEXT (JSON) containing ENGINE_OUTCOME and state: + - Base your narration strictly on those facts; do not invent or contradict them. + - Do NOT change inventory, unlock doors, reveal items, or otherwise alter state. + - Use 2–5 sentences, second person, present tense. Be concise and vivid. + - If an action is impossible, explain why using the provided facts. + - You may list relevant observations. + + Never reveal hidden mechanics. Maintain internal consistency with prior events + and the scenario facts below. + +scenario: + title: "Locked Room Test" + setting: | + A small stone chamber lit by a thin shaft of light. The north wall holds a + heavy wooden door with iron bands. The floor is made of worn flagstones; + one near the center looks slightly loose. + objectives: + - Find the hidden brass key. + - Take the key and add it to your inventory. + - Unlock the door using the brass key. + - Open the door and exit the chamber to complete the scenario. + constraints: + - The LLM is a narrator only; the ENGINE determines outcomes. + - The key starts hidden and must be revealed by searching before it can be taken. + - The door begins locked and can only be unlocked with the brass key. + - The scenario completes when the door is opened and the player exits. + style: + tone: "Tense, grounded, concise" + reading_level: "General audience" + facts: + - Door starts locked (door_locked=true) and closed (door_open=false). + - A brass key is hidden beneath a slightly loose flagstone (key_hidden=true). + - Inventory starts empty. + locations: + - "Stone chamber" + inventory_start: [] + +rules: + - Do not contradict established scenario facts or ENGINE outcomes. + - Keep inventory accurate only as reported by the ENGINE events/state. + - If the player attempts a dangerous or impossible action, clarify and explain why. + - When multiple actions are issued, focus on the first and summarize the rest as pending or ask for a choice. + +start_message: | + A narrow beam of daylight slices through the dust, picking out motes that drift + above worn flagstones. A heavy wooden door reinforced with iron faces north; its + lock glints, unmoved for years. Near the center of the floor, one flagstone sits + just a touch askew, as if something beneath has lifted it by a hair. + + What do you do? + +rules: + - Never contradict established scenario facts. + - Keep inventory accurate and list changes explicitly. + - When dangerous actions are attempted, warn the player and ask for confirmation. + - If the player issues multiple actions, prioritize the first and summarize the rest as pending or ask to choose. + - Do not roll dice; infer outcomes logically from context and constraints. + +start_message: | + You stand alone in a dim stone chamber. A heavy wooden door reinforced with iron faces north. + \ No newline at end of file diff --git a/conversation.py b/conversation.py new file mode 100644 index 0000000..2d4c5e6 --- /dev/null +++ b/conversation.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Conversation history management for the LLM interaction system. +""" + + +class ConversationManager: + """Manages conversation history between user and LLM.""" + + def __init__(self): + """Initialize the conversation manager.""" + self.history = [] + + def add_user_message(self, message): + """Add a user message to the conversation history. + + Args: + message (str): The user's message + """ + self.history.append({ + "role": "user", + "content": message + }) + + def add_system_message(self, message): + """Add a system message to the conversation history. + + Args: + message (str): The system message + """ + self.history.append({ + "role": "system", + "content": message + }) + + def add_assistant_message(self, message): + """Add an assistant message to the conversation history. + + Args: + message (str): The assistant's message + """ + self.history.append({ + "role": "assistant", + "content": message + }) + + def get_history(self): + """Get the complete conversation history. + + Returns: + list: List of message dictionaries + """ + return self.history + + def clear_history(self): + """Clear the conversation history.""" + self.history = [] + + def get_last_user_message(self): + """Get the last user message from history. + + Returns: + str: The last user message, or None if no user messages + """ + for message in reversed(self.history): + if message["role"] == "user": + return message["content"] + return None + + def get_last_assistant_message(self): + """Get the last assistant message from history. + + Returns: + str: The last assistant message, or None if no assistant messages + """ + for message in reversed(self.history): + if message["role"] == "assistant": + return message["content"] + return None \ No newline at end of file diff --git a/deploy_to_portainer.py b/deploy_to_portainer.py new file mode 100644 index 0000000..47923e4 --- /dev/null +++ b/deploy_to_portainer.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" +Deployment script for deploying the text-adventure app to Portainer. +""" + +import requests +import json +import sys +import os +from pathlib import Path +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +class PortainerDeployer: + """Class to handle deployment to Portainer.""" + + def __init__(self, portainer_url, username, password): + """Initialize the Portainer deployer. + + Args: + portainer_url (str): URL of the Portainer instance (e.g., http://10.0.0.199:9000) + username (str): Portainer username + password (str): Portainer password + """ + self.portainer_url = portainer_url.rstrip('/') + self.username = username + self.password = password + self.auth_token = None + self.endpoint_id = None + + def authenticate(self): + """Authenticate with Portainer and get JWT token. + + Returns: + bool: True if authentication successful, False otherwise + """ + try: + url = f"{self.portainer_url}/api/auth" + payload = { + "username": self.username, + "password": self.password + } + + response = requests.post(url, json=payload) + response.raise_for_status() + + data = response.json() + self.auth_token = data.get('jwt') + + if self.auth_token: + print("✓ Successfully authenticated with Portainer") + return True + else: + print("✗ Failed to get authentication token") + return False + + except requests.exceptions.RequestException as e: + print(f"✗ Error authenticating with Portainer: {e}") + return False + + def get_endpoints(self): + """Get list of Docker endpoints. + + Returns: + list: List of endpoints or None if failed + """ + try: + url = f"{self.portainer_url}/api/endpoints" + headers = {"Authorization": f"Bearer {self.auth_token}"} + + response = requests.get(url, headers=headers) + response.raise_for_status() + + endpoints = response.json() + return endpoints + + except requests.exceptions.RequestException as e: + print(f"✗ Error getting endpoints: {e}") + return None + + def select_endpoint(self, endpoint_name=None): + """Select a Docker endpoint to deploy to. + + Args: + endpoint_name (str, optional): Name of specific endpoint to use + + Returns: + str: Endpoint ID or None if failed + """ + endpoints = self.get_endpoints() + if not endpoints: + return None + + if endpoint_name: + # Find specific endpoint by name + for endpoint in endpoints: + if endpoint.get('Name') == endpoint_name: + self.endpoint_id = endpoint.get('Id') + print(f"✓ Selected endpoint: {endpoint_name} (ID: {self.endpoint_id})") + return self.endpoint_id + + print(f"✗ Endpoint '{endpoint_name}' not found") + return None + else: + # Use first endpoint if no specific one requested + if endpoints: + endpoint = endpoints[0] + self.endpoint_id = endpoint.get('Id') + print(f"✓ Selected endpoint: {endpoint.get('Name')} (ID: {self.endpoint_id})") + return self.endpoint_id + else: + print("✗ No endpoints found") + return None + + def deploy_stack(self, stack_name, compose_file_path): + """Deploy a Docker stack using a compose file. + + Args: + stack_name (str): Name for the stack + compose_file_path (str): Path to docker-compose.yml file + + Returns: + bool: True if deployment successful, False otherwise + """ + try: + if not self.endpoint_id: + print("✗ No endpoint selected") + return False + + # Read compose file + if not os.path.exists(compose_file_path): + print(f"✗ Compose file not found: {compose_file_path}") + return False + + with open(compose_file_path, 'r') as f: + compose_content = f.read() + + # Deploy stack + url = f"{self.portainer_url}/api/stacks" + headers = {"Authorization": f"Bearer {self.auth_token}"} + params = { + "type": 2, # Swarm stack + "method": "string", + "endpointId": self.endpoint_id + } + + payload = { + "name": stack_name, + "StackFileContent": compose_content + } + + response = requests.post(url, headers=headers, params=params, json=payload) + + if response.status_code == 200: + print(f"✓ Stack '{stack_name}' deployed successfully") + return True + else: + print(f"✗ Failed to deploy stack: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"✗ Error deploying stack: {e}") + return False + + def deploy_container(self, container_name, image_name, network_mode="default"): + """Deploy a single container. + + Args: + container_name (str): Name for the container + image_name (str): Docker image to use + network_mode (str): Network mode for the container + + Returns: + bool: True if deployment successful, False otherwise + """ + try: + if not self.endpoint_id: + print("✗ No endpoint selected") + return False + + # Deploy container + url = f"{self.portainer_url}/api/endpoints/{self.endpoint_id}/docker/containers/create" + headers = {"Authorization": f"Bearer {self.auth_token}"} + + # Container configuration + payload = { + "Image": image_name, + "name": container_name, + "HostConfig": { + "NetworkMode": network_mode, + "RestartPolicy": { + "Name": "unless-stopped" + } + }, + "Tty": True, + "OpenStdin": True + } + + # Create container + response = requests.post(url, headers=headers, json=payload) + + if response.status_code == 201: + container_data = response.json() + container_id = container_data.get('Id') + print(f"✓ Container '{container_name}' created successfully") + + # Start container + start_url = f"{self.portainer_url}/api/endpoints/{self.endpoint_id}/docker/containers/{container_id}/start" + start_response = requests.post(start_url, headers=headers) + + if start_response.status_code == 204: + print(f"✓ Container '{container_name}' started successfully") + return True + else: + print(f"✗ Failed to start container: {start_response.status_code}") + return False + else: + print(f"✗ Failed to create container: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"✗ Error deploying container: {e}") + return False + + +def main(): + """Main function to deploy the application to Portainer.""" + print("Deploying text-adventure app to Portainer...") + print("=" * 50) + + # Configuration from .env file or environment variables + PORTAINER_URL = os.environ.get('PORTAINER_URL', 'http://10.0.0.199:9000') + PORTAINER_USERNAME = os.environ.get('PORTAINER_USERNAME', 'admin') + PORTAINER_PASSWORD = os.environ.get('PORTAINER_PASSWORD', 'password') + ENDPOINT_NAME = os.environ.get('PORTAINER_ENDPOINT', None) + + print(f"Using Portainer URL: {PORTAINER_URL}") + print(f"Using Username: {PORTAINER_USERNAME}") + + # Create deployer instance + deployer = PortainerDeployer(PORTAINER_URL, PORTAINER_USERNAME, PORTAINER_PASSWORD) + + # Authenticate + if not deployer.authenticate(): + sys.exit(1) + + # Select endpoint + if not deployer.select_endpoint(ENDPOINT_NAME): + sys.exit(1) + + # Deploy as container (simpler approach for this app) + print("\nDeploying as container...") + success = deployer.deploy_container( + container_name="text-adventure-app", + image_name="text-adventure:latest", + network_mode="host" # Use host network to access LM Studio + ) + + if success: + print("\n✓ Deployment completed successfully!") + print("You can now access your container through Portainer") + else: + print("\n✗ Deployment failed!") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..eea19fb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + text-adventure: + build: . + container_name: text-adventure-app + stdin_open: true + tty: true + volumes: + # Mount the current directory to /app for development + - .:/app + environment: + # Environment variables can be set here + - PYTHONUNBUFFERED=1 + # If you need to connect to a service on the host machine + # network_mode: "host" + + # For production, you might want to use: + ports: + - "8000:8000" \ No newline at end of file diff --git a/game_config.py b/game_config.py new file mode 100644 index 0000000..24f660b --- /dev/null +++ b/game_config.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +""" +Game LLM behavior configuration loader. + +Loads YAML configuration that defines: +- system_prompt: Injected as the first "system" message for chat completions +- scenario, rules: Arbitrary metadata available to the app if needed +- start_message: Initial assistant message shown to the user +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Optional, Union + +import yaml + + +@dataclass +class GameConfig: + system_prompt: str = "" + scenario: Dict[str, Any] = field(default_factory=dict) + rules: list[str] = field(default_factory=list) + start_message: str = "" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "GameConfig": + return cls( + system_prompt=str(data.get("system_prompt", "") or ""), + scenario=dict(data.get("scenario", {}) or {}), + rules=list(data.get("rules", []) or []), + start_message=str(data.get("start_message", "") or ""), + ) + + +def load_game_config(path: Union[str, Path] = "config/game_config.yaml") -> GameConfig: + """ + Load the game configuration from YAML. Returns defaults if the file is missing or invalid. + + Args: + path: Path to the YAML config file. + + Returns: + GameConfig: Parsed configuration or defaults. + """ + p = Path(path) + if not p.exists(): + # Return defaults if config is missing + return GameConfig() + + try: + with p.open("r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + if not isinstance(raw, dict): + return GameConfig() + return GameConfig.from_dict(raw) + except Exception: + # Fail-closed to defaults to keep the app usable + return GameConfig() \ No newline at end of file diff --git a/game_state.py b/game_state.py new file mode 100644 index 0000000..94e94e0 --- /dev/null +++ b/game_state.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +Deterministic game state engine. + +The LLM is responsible ONLY for narrative description. +All world logic and outcomes are computed here and persisted per session. + +Scenario (Stage 1): +- Single room +- Locked door +- Hidden key (revealed by searching) +- Player must find key, add it to inventory, unlock and open the door to complete + +State bootstrap: +- Loads canonical initial state from JSON in ./state: + * items.json (key) + * locks.json (door lock) + * exits.json (door/exit) + * room.json (room descriptors and references to ids) + * containers.json (e.g., the loose flagstone that can hide/reveal the key) +These files may cross-reference each other via ids; this class resolves them +and produces a consolidated in-memory state used by the engine. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +from pathlib import Path +import json + + +@dataclass +class GameState: + # World facts + room_description: str = ( + "A dim stone chamber with worn flagstones and a heavy wooden door to the north. " + "Dust gathers in the corners, and one flagstone near the center looks slightly loose." + ) + + door_description: str = "A heavy wooden door reinforced with iron; its lock glints, unmoved for years." + door_locked: bool = True + door_open: bool = False + door_id: Optional[int] = None + lock_id: Optional[int] = None + lock_key_id: Optional[int] = None + + # Key lifecycle + key_description: str = "A brass key with a tarnished surface." + key_hidden: bool = True # Not yet discoverable by name + key_revealed: bool = False # Revealed by searching + key_taken: bool = False + key_id: Optional[int] = None + + # Container (e.g., loose flagstone) that may hide the key + container_id: Optional[int] = None + + # Player + inventory: List[str] = field(default_factory=list) + + # Exits metadata exposed to UI/LLM (direction -> {id,type}) + exits: Dict[str, Any] = field(default_factory=dict) + + # Progress + completed: bool = False + + def to_public_dict(self) -> Dict[str, Any]: + """Minimal canonical state that the LLM may see (facts only).""" + return { + "room": { + "description": self.room_description, + "exits": {k: dict(v) for k, v in self.exits.items()}, + }, + "door": { + "id": self.door_id, + "description": self.door_description, + "locked": self.door_locked, + "open": self.door_open, + "lock_id": self.lock_id, + "key_id": self.lock_key_id, + }, + "key": { + "id": self.key_id, + "description": self.key_description, + "revealed": self.key_revealed, + "taken": self.key_taken, + }, + "inventory": list(self.inventory), + "completed": self.completed, + } + + # ---------- Bootstrap from JSON files ---------- + + @classmethod + def from_files(cls, state_dir: str | Path = "state") -> "GameState": + """ + Create a GameState initialized from JSON files in state_dir. + Files are optional; sensible defaults are used if missing. + + Expected files: + - items.json: key item (id, type, description, hidden, revealed, taken) + - locks.json: door lock (id, type, description, locked, open, key_id) + - exits.json: door/exit (id, type, description, lock_id, open/locked overrides) + - room.json: room (id, type, description, exits {dir: exit_id}, items [ids], containers [ids]) + - containers.json: container that can hide/reveal item (id, hidden, revealed, openable, open, description) + """ + base = Path(state_dir) + + def _load_json(p: Path) -> Dict[str, Any]: + try: + if p.exists() and p.stat().st_size > 0: + with p.open("r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + pass + return {} + + items = _load_json(base / "items.json") + locks = _load_json(base / "locks.json") + exits = _load_json(base / "exits.json") + room = _load_json(base / "room.json") + containers = _load_json(base / "containers.json") + + # Resolve room description + default_room = ( + "A dim stone chamber with worn flagstones and a heavy wooden door to the north. " + "Dust gathers in the corners, and one flagstone near the center looks slightly loose." + ) + room_description = str(room.get("description", default_room)) if isinstance(room, dict) else default_room + + # Resolve exits metadata from room references (direction -> exit_id) + exits_meta: Dict[str, Any] = {} + if isinstance(room, dict) and isinstance(room.get("exits"), dict): + for direction, ex_id in room["exits"].items(): + exits_meta[direction] = {"id": ex_id, "type": (exits.get("type", "door") if isinstance(exits, dict) else "door")} + + # Resolve door description/flags. Prefer locks.json, fallback to exits.json + door_description = ( + str(locks.get("description")) + if isinstance(locks, dict) and "description" in locks + else (str(exits.get("description")) if isinstance(exits, dict) and "description" in exits else + "A heavy wooden door reinforced with iron; its lock glints, unmoved for years.") + ) + door_locked = bool(locks.get("locked", True)) if isinstance(locks, dict) else bool(exits.get("locked", True)) if isinstance(exits, dict) else True + door_open = bool(locks.get("open", False)) if isinstance(locks, dict) else bool(exits.get("open", False)) if isinstance(exits, dict) else False + door_id = int(exits.get("id")) if isinstance(exits, dict) and "id" in exits else None + lock_id = int(locks.get("id")) if isinstance(locks, dict) and "id" in locks else (int(exits.get("lock_id")) if isinstance(exits, dict) and "lock_id" in exits else None) + lock_key_id = int(locks.get("key_id")) if isinstance(locks, dict) and "key_id" in locks else None + + # Resolve key description/flags and ids + key_description = str(items.get("description", "A brass key with a tarnished surface.")) if isinstance(items, dict) else "A brass key with a tarnished surface." + key_hidden = bool(items.get("hidden", True)) if isinstance(items, dict) else True + key_revealed = bool(items.get("revealed", False)) if isinstance(items, dict) else False + key_taken = bool(items.get("taken", False)) if isinstance(items, dict) else False + key_id = int(items.get("id")) if isinstance(items, dict) and "id" in items else None + + # Container influence (if the referenced container houses the key) + container_id = None + if isinstance(room, dict) and isinstance(room.get("containers"), list) and len(room["containers"]) > 0: + container_id = room["containers"][0] + if isinstance(containers, dict): + # If a single container is defined and either not referenced or the id matches, merge visibility flags + if container_id is None or containers.get("id") == container_id: + container_hidden = bool(containers.get("hidden", False)) + container_revealed = bool(containers.get("revealed", False)) + # Hidden if either marks hidden and not revealed yet + key_hidden = (key_hidden or container_hidden) and not (key_revealed or container_revealed) + key_revealed = key_revealed or container_revealed + + return cls( + room_description=room_description, + door_description=door_description, + door_locked=door_locked, + door_open=door_open, + door_id=door_id, + lock_id=lock_id, + lock_key_id=lock_key_id, + key_description=key_description, + key_hidden=key_hidden, + key_revealed=key_revealed, + key_taken=key_taken, + key_id=key_id, + container_id=container_id, + exits=exits_meta, + ) + + # ------------- Intent handling ------------- + + def apply_action(self, user_text: str) -> Dict[str, Any]: + """ + Parse a user action, update state deterministically, and return an ENGINE_OUTCOME + suitable for feeding into the LLM narrator. + + Returns: + dict with: + - events: List[str] describing factual outcomes (not narrative prose) + """ + text = (user_text or "").strip().lower() + events: List[str] = [] + + if not text: + return {"events": ["No action provided."]} + + # Simple keyword intent parsing + def has(*words: str) -> bool: + return all(w in text for w in words) + + # Inventory check + if has("inventory") or has("items") or has("bag"): + inv = ", ".join(self.inventory) if self.inventory else "empty" + events.append(f"Inventory checked; current items: {inv}.") + return {"events": events} + + # Look/examine room + if has("look") or has("examine") or has("observe") or has("describe"): + events.append("Player surveys the room; no state change.") + return {"events": events} + + # Search actions reveal the key (once) + if has("search") or has("inspect") or has("check") or has("look around") or has("look closely"): + if not self.key_revealed and self.key_hidden: + self.key_revealed = True + self.key_hidden = False + events.append("A small brass key is revealed beneath a loose flagstone.") + else: + events.append("Search performed; nothing new is revealed.") + return {"events": events} + + # Take/pick up the key + if ("key" in text) and (has("take") or has("pick") or has("grab") or has("get")): + if not self.key_revealed and not self.key_taken: + events.append("Key not visible; cannot take what has not been revealed.") + elif self.key_taken: + events.append("Key already in inventory; no change.") + else: + self.key_taken = True + if "brass key" not in self.inventory: + self.inventory.append("brass key") + events.append("Player picks up the brass key and adds it to inventory.") + return {"events": events} + + # Unlock door with key + if ("door" in text) and (has("unlock") or has("use key") or (has("use") and "key" in text)): + if self.door_open: + events.append("Door is already open; unlocking unnecessary.") + elif not self.key_taken: + events.append("Player lacks the brass key; door remains locked.") + elif not self.door_locked: + events.append("Door already unlocked; no change.") + elif (self.lock_key_id is not None and self.key_id is not None and self.lock_key_id != self.key_id): + events.append("The brass key does not fit this lock; the door remains locked.") + else: + self.door_locked = False + events.append("Door is unlocked with the brass key.") + return {"events": events} + + # Open door / go through door + if ("door" in text) and (has("open") or has("go through") or has("enter")): + if self.door_open: + events.append("Door is already open; state unchanged.") + elif self.door_locked: + events.append("Door is locked; opening fails.") + else: + self.door_open = True + self.completed = True + events.append("Door is opened and the player exits the chamber. Scenario complete.") + return {"events": events} + + # Use key on door (explicit phrasing) + if has("use", "key") and ("door" in text): + if not self.key_taken: + events.append("Player attempts to use a key they do not have.") + elif self.door_locked and (self.lock_key_id is not None and self.key_id is not None and self.lock_key_id != self.key_id): + events.append("The brass key does not fit this lock; the door remains locked.") + elif self.door_locked: + self.door_locked = False + events.append("Door is unlocked with the brass key.") + else: + events.append("Door already unlocked; no change.") + return {"events": events} + + # Fallback: no matching intent + events.append("No recognized action; state unchanged.") + return {"events": events} \ No newline at end of file diff --git a/interface.py b/interface.py new file mode 100644 index 0000000..defee7e --- /dev/null +++ b/interface.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Text interface for the LLM interaction system. +Handles input/output operations with the user. +""" + + +class TextInterface: + """Handles text-based input and output operations.""" + + def __init__(self): + """Initialize the text interface.""" + pass + + def get_user_input(self): + """Get input from the user. + + Returns: + str: The user's input text + """ + try: + user_input = input("> ") + return user_input + except EOFError: + # Handle Ctrl+D (EOF) gracefully + return "quit" + + def display_response(self, response): + """Display the LLM response to the user. + + Args: + response (str): The response text to display + """ + print(response) + print() # Add a blank line for readability + + def display_system_message(self, message): + """Display a system message to the user. + + Args: + message (str): The system message to display + """ + print(f"[System] {message}") + print() \ No newline at end of file diff --git a/llm_client.py b/llm_client.py new file mode 100644 index 0000000..ff731ff --- /dev/null +++ b/llm_client.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +LLM client for communicating with LM Studio. +""" + + +import requests +import json + + +class LLMClient: + """Client for communicating with LM Studio.""" + + def __init__(self, config): + """Initialize the LLM client. + + Args: + config (Config): Configuration object + """ + self.config = config + self.session = requests.Session() + self.session.timeout = self.config.REQUEST_TIMEOUT + + def get_response(self, messages): + """Get a response from the LLM. + + Args: + messages (list): List of message dictionaries + + Returns: + str: The LLM response text + """ + try: + # Prepare the request payload + payload = { + "model": self.config.DEFAULT_MODEL, + "messages": messages, + "temperature": self.config.TEMPERATURE, + "max_tokens": self.config.MAX_TOKENS + } + + # Send request to LM Studio + response = self.session.post( + self.config.get_chat_completions_url(), + headers={"Content-Type": "application/json"}, + data=json.dumps(payload) + ) + + # Raise an exception for bad status codes + response.raise_for_status() + + # Parse the response + response_data = response.json() + + # Extract the assistant's message + assistant_message = response_data["choices"][0]["message"]["content"] + + return assistant_message + + except requests.exceptions.RequestException as e: + raise Exception(f"Error communicating with LM Studio: {e}") + except (KeyError, IndexError) as e: + raise Exception(f"Error parsing LM Studio response: {e}") + except json.JSONDecodeError as e: + raise Exception(f"Error decoding JSON response: {e}") + + def test_connection(self): + """Test the connection to LM Studio. + + Returns: + bool: True if connection successful, False otherwise + """ + try: + # Try to get available models (simple connection test) + response = self.session.get(f"{self.config.get_api_url()}/models") + response.raise_for_status() + return True + except requests.exceptions.RequestException: + return False + + def update_model(self, model_name): + """Update the model used for completions. + + Args: + model_name (str): Name of the model to use + """ + self.config.DEFAULT_MODEL = model_name \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..a2faa9a --- /dev/null +++ b/main.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +Main entry point for the text-based LLM interaction system. +""" + +import sys +import json +from interface import TextInterface +from llm_client import LLMClient +from conversation import ConversationManager +from config import Config +from game_config import load_game_config +from game_state import GameState + + +def main(): + """Main function to run the text-based LLM interaction system.""" + print("Text-Based LLM Interaction System") + print("Type 'quit' to exit the program") + print("-" * 40) + + # Initialize components + config = Config() + interface = TextInterface() + conversation_manager = ConversationManager() + llm_client = LLMClient(config) + gs = GameState.from_files("state") + + # Load game behavior config and seed conversation + gamecfg = load_game_config() + if gamecfg.system_prompt: + conversation_manager.add_system_message(gamecfg.system_prompt) + if gamecfg.start_message: + interface.display_response(gamecfg.start_message) + + # Main interaction loop + while True: + try: + # Get user input + user_input = interface.get_user_input() + + # Check for exit command + if user_input.lower() in ['quit', 'exit', 'q']: + print("Goodbye!") + break + + # Add user message to conversation + conversation_manager.add_user_message(user_input) + + # Apply deterministic game logic first + engine_outcome = gs.apply_action(user_input) # {"events": [...]} + + # Provide a transient system message with canonical facts for narration + narrator_directive = { + "ENGINE_OUTCOME": { + "events": engine_outcome.get("events", []), + "state": gs.to_public_dict(), + }, + "NARRATION_RULES": [ + "Narrate strictly according to ENGINE_OUTCOME. Do not invent state.", + "Do not add items, unlock objects, or change inventory; the engine already did that.", + "Use 2-5 sentences, present tense, second person. Be concise and vivid.", + "If the action was impossible, explain why using the facts provided.", + ], + } + transient_system = { + "role": "system", + "content": "ENGINE CONTEXT (JSON): " + json.dumps(narrator_directive), + } + + # Get response from LLM with engine context + messages = list(conversation_manager.get_history()) + [transient_system] + response = llm_client.get_response(messages) + + # Display response + interface.display_response(response) + + # Add assistant message to conversation + conversation_manager.add_assistant_message(response) + + # End scenario if completed + if gs.completed: + interface.display_system_message("Scenario complete. You unlocked the door and escaped.") + break + + except KeyboardInterrupt: + print("\nGoodbye!") + break + except Exception as e: + print(f"An error occurred: {e}") + break + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..818f7bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +requests>=2.25.0 +python-dotenv>=0.19.0 +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +aiofiles>=23.2.1 +PyYAML>=6.0.1 \ No newline at end of file diff --git a/setup_guide.md b/setup_guide.md new file mode 100644 index 0000000..e0bf0fa --- /dev/null +++ b/setup_guide.md @@ -0,0 +1,283 @@ +# Setup Guide for Text-Based LLM Interaction System (Linux) + +This guide describes a clean Linux-first setup (tested on Ubuntu/Debian). It removes macOS/Windows specifics and focuses on a reliable developer workflow with pyenv, virtual environments, Docker, and Portainer. + +## Prerequisites + +- Git +- curl or wget +- build tools (gcc, make, etc.) +- Docker Engine and Docker Compose plugin + +Recommended (optional but helpful): +- net-tools or iproute2 for networking diagnostics +- ca-certificates + +On Ubuntu/Debian you can install Docker and the Compose plugin via apt: + +```bash +sudo apt update +sudo apt install -y docker.io docker-compose-plugin +# Allow running docker without sudo (re-login required) +sudo usermod -aG docker "$USER" +``` + +## Installing pyenv (Ubuntu/Debian) + +Install dependencies required to build CPython: + +```bash +sudo apt update +sudo apt install -y make build-essential libssl-dev zlib1g-dev \ + libbz2-dev libreadline-dev libsqlite3-dev wget curl llvm \ + libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev \ + libffi-dev liblzma-dev +``` + +Install pyenv: + +```bash +curl https://pyenv.run | bash +``` + +Add pyenv to your shell (bash; recommended on Linux): + +```bash +echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.profile +echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.profile +echo 'eval "$(pyenv init --path)"' >> ~/.profile + +# Interactive shells +echo 'eval "$(pyenv init -)"' >> ~/.bashrc + +# Reload your shell +source ~/.profile +source ~/.bashrc +``` + +Using zsh on Linux? Add to ~/.zprofile and ~/.zshrc instead: + +```bash +echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.zprofile +echo 'export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.zprofile +echo 'eval "$(pyenv init --path)"' >> ~/.zprofile +echo 'eval "$(pyenv init -)"' >> ~/.zshrc +source ~/.zprofile +source ~/.zshrc +``` + +## Installing Python with pyenv + +```bash +# List available Python versions +pyenv install --list + +# Install a specific Python version (project default) +pyenv install 3.9.16 + +# Set it as your global default (optional) +pyenv global 3.9.16 +``` + +Note: If you prefer a newer interpreter and your tooling supports it, Python 3.11.x also works well. + +## Creating a Virtual Environment + +Choose ONE of the methods below. + +### Method 1: Using pyenv-virtualenv (recommended) + +```bash +# Install pyenv-virtualenv plugin +git clone https://github.com/pyenv/pyenv-virtualenv.git "$(pyenv root)"/plugins/pyenv-virtualenv + +# Initialize in your interactive shell +echo 'eval "$(pyenv virtualenv-init -)"' >> ~/.bashrc +source ~/.bashrc + +# Create and activate a virtual environment +pyenv virtualenv 3.9.16 text-adventure +pyenv local text-adventure # auto-activates in this directory +# or activate manually +pyenv activate text-adventure +``` + +### Method 2: Using Python's built-in venv + +```bash +# Use the selected Python version in this directory +pyenv local 3.9.16 + +# Create and activate venv +python -m venv .venv +source .venv/bin/activate +``` + +## Installing Project Dependencies + +With your virtual environment active: + +```bash +python -m pip install --upgrade pip setuptools wheel +pip install -r requirements.txt +``` + +## Verifying Setup + +```bash +# Check Python version +python --version + +# Check installed packages +pip list + +# Run basic tests +python test_system.py +# Optionally: +python test_llm_connection.py +python test_llm_exchange.py +``` + +## Docker (Linux) + +Docker provides a clean, reproducible environment. + +### Build and Run + +```bash +# Build the image +docker build -t text-adventure . + +# Run using host networking (Linux-only) +docker run -it --rm --network host -v "$PWD":/app text-adventure +``` + +### Docker Compose (v2 CLI) + +Update [docker-compose.yml](docker-compose.yml) as needed. On Linux you may use host networking: + +```yaml +services: + text-adventure: + build: . + network_mode: "host" # Linux-only +``` + +Then: + +```bash +docker compose up --build +# Run detached +docker compose up -d --build +# Stop and remove +docker compose down +``` + +### Connecting to LM Studio from a container + +LM Studio is assumed to run on the Linux host (default in [config.py](config.py)). Prefer using the special hostname host.docker.internal inside containers. + +Option A (recommended when not using host network): add an extra_hosts mapping using Docker’s host-gateway: + +```yaml +services: + text-adventure: + build: . + extra_hosts: + - "host.docker.internal:host-gateway" +``` + +Then in [config.py](config.py), set: + +```python +self.LM_STUDIO_HOST = "host.docker.internal" +self.LM_STUDIO_PORT = 1234 +``` + +Option B (Linux-only): use host networking (container shares host network namespace). In this case keep LM_STUDIO_HOST as 127.0.0.1 or the host’s IP address. + +Fallback: If neither applies, you can use your Docker bridge gateway IP (often 172.17.0.1), but this can vary by system. + +Connectivity quick check from inside the container: + +```bash +docker compose exec text-adventure python - <<'PY' +import requests +import sys +url = "http://host.docker.internal:1234/v1/models" +try: + r = requests.get(url, timeout=5) + print(r.status_code, r.text[:200]) +except Exception as e: + print("ERROR:", e) + sys.exit(1) +PY +``` + +## Portainer Deployment (Linux) + +Portainer simplifies Docker management via the web UI. + +### Prerequisites + +- Portainer instance reachable (e.g., http://10.0.0.199:9000) +- Valid Portainer credentials +- Docker image available (local or registry) + +Create a local env file and never commit secrets: + +```bash +cp .env.example .env +# edit .env and set PORTAINER_URL, PORTAINER_USERNAME, PORTAINER_PASSWORD +``` + +### Deploy + +The script [deploy_to_portainer.py](deploy_to_portainer.py) deploys a single container using host networking, which works on Linux: + +```bash +python deploy_to_portainer.py +``` + +You can also override environment variables: + +```bash +export PORTAINER_URL=http://10.0.0.199:9000 +export PORTAINER_USERNAME=admin +export PORTAINER_PASSWORD=yourpassword +python deploy_to_portainer.py +``` + +After deployment, verify connectivity to LM Studio using the container exec method shown above. + +## Troubleshooting (Linux) + +- pyenv: command not found + - Ensure the init lines exist in ~/.profile and ~/.bashrc, then restart your terminal: + - eval "$(pyenv init --path)" in ~/.profile + - eval "$(pyenv init -)" in ~/.bashrc + +- Python build errors (e.g., "zlib not available") + - Confirm all build deps are installed (see Installing pyenv). + +- Virtual environment activation issues + - For venv: source .venv/bin/activate + - For pyenv-virtualenv: pyenv activate text-adventure + +- Docker permission errors (e.g., "permission denied" / cannot connect to Docker daemon) + - Add your user to the docker group and re-login: + sudo usermod -aG docker "$USER" + +- docker compose not found + - Install the Compose plugin: sudo apt install docker-compose-plugin + +- Container cannot reach LM Studio + - If using extra_hosts: verify host-gateway mapping and LM_STUDIO_HOST=host.docker.internal in [config.py](config.py). + - If using host network: ensure LM Studio is listening on 0.0.0.0 or localhost as appropriate. + - Try the connectivity check snippet above; if it fails, verify firewall rules and that LM Studio is running. + +## Notes + +- The LM Studio address 10.0.0.200:1234 in [config.py](config.py) is a placeholder. Adjust it to your environment as described above. +- Do not commit your [.env](.env) file. Use [.env.example](.env.example) as a template. +- Use docker compose (v2) commands instead of the deprecated docker-compose (v1) binary. \ No newline at end of file diff --git a/state/containers.json b/state/containers.json new file mode 100644 index 0000000..195d678 --- /dev/null +++ b/state/containers.json @@ -0,0 +1,12 @@ +{ + "id": 1, + "type": "recessed", + "hidden": true, + "revealed": false, + "openable": true, + "open": false, + "lock_id": 0, + "weight": 1, + "description": "A flagstone that looks slightly loose.", + "contents": [1] +} \ No newline at end of file diff --git a/state/exits.json b/state/exits.json new file mode 100644 index 0000000..b280431 --- /dev/null +++ b/state/exits.json @@ -0,0 +1,10 @@ +{ + "id": 1, + "type": "door", + "description": "A heavy wooden door reinforced with iron faces north; its lock glints, unmoved for years.", + "lock_id": 1, + "locked": true, + "openable": true, + "open": false, + "key": "key" +} \ No newline at end of file diff --git a/state/items.json b/state/items.json new file mode 100644 index 0000000..e14035f --- /dev/null +++ b/state/items.json @@ -0,0 +1,8 @@ +{ + "id": 1, + "type": "key", + "description": "A brass key with a tarnished surface.", + "hidden": true, + "revealed": false, + "taken": false +} \ No newline at end of file diff --git a/state/locks.json b/state/locks.json new file mode 100644 index 0000000..f23a7bb --- /dev/null +++ b/state/locks.json @@ -0,0 +1,9 @@ +{ + "id": 1, + "type": "door_lock", + "description": "A simple wooden door lock.", + "locked": true, + "openable": true, + "open": false, + "key_id": 1 +} \ No newline at end of file diff --git a/state/room.json b/state/room.json new file mode 100644 index 0000000..d4b29ff --- /dev/null +++ b/state/room.json @@ -0,0 +1,10 @@ +{ + "id": "1", + "type": "room", + "description": "A dim stone chamber with worn flagstones and a heavy wooden door to the north. Dust gathers in the corners, and one flagstone near the center looks slightly loose.", + "exits": { + "north": 1 + }, + "items": [], + "containers": [1] +} \ No newline at end of file diff --git a/test_interface.py b/test_interface.py new file mode 100644 index 0000000..737dc0e --- /dev/null +++ b/test_interface.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +Test script for the text interface. +""" + +from interface import TextInterface + + +def test_interface(): + """Test the text interface functionality.""" + print("Testing Text Interface") + print("Type 'quit' to exit the test") + print("-" * 30) + + # Create interface instance + interface = TextInterface() + + # Test loop + while True: + try: + # Get user input + user_input = interface.get_user_input() + + # Check for exit command + if user_input.lower() in ['quit', 'exit', 'q']: + print("Exiting test...") + break + + # Display response + interface.display_response(f"You entered: {user_input}") + + except KeyboardInterrupt: + print("\nTest interrupted!") + break + + +if __name__ == "__main__": + test_interface() \ No newline at end of file diff --git a/test_llm_connection.py b/test_llm_connection.py new file mode 100644 index 0000000..614505f --- /dev/null +++ b/test_llm_connection.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +""" +Test script for connecting to LM Studio. +""" + +from config import Config +from llm_client import LLMClient + + +def test_connection(): + """Test the connection to LM Studio.""" + print("Testing connection to LM Studio...") + print("-" * 30) + + # Create config and client + config = Config() + llm_client = LLMClient(config) + + # Display connection details + print(f"Host: {config.LM_STUDIO_HOST}") + print(f"Port: {config.LM_STUDIO_PORT}") + print(f"API URL: {config.get_api_url()}") + print(f"Chat Completions URL: {config.get_chat_completions_url()}") + print() + + # Test connection + try: + success = llm_client.test_connection() + if success: + print("✓ Connection to LM Studio successful!") + else: + print("✗ Failed to connect to LM Studio") + except Exception as e: + print(f"✗ Error testing connection: {e}") + + +if __name__ == "__main__": + test_connection() \ No newline at end of file diff --git a/test_llm_exchange.py b/test_llm_exchange.py new file mode 100644 index 0000000..ab982e7 --- /dev/null +++ b/test_llm_exchange.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +Test script for basic message exchange with LLM. +""" + +from config import Config +from llm_client import LLMClient +from conversation import ConversationManager + + +def test_message_exchange(): + """Test basic message exchange with LLM.""" + print("Testing message exchange with LLM...") + print("-" * 40) + + # Create components + config = Config() + llm_client = LLMClient(config) + conversation_manager = ConversationManager() + + # Test connection first + try: + success = llm_client.test_connection() + if not success: + print("✗ Failed to connect to LM Studio") + return + else: + print("✓ Connected to LM Studio") + except Exception as e: + print(f"✗ Error testing connection: {e}") + return + + # Add a test message to conversation + test_message = "Hello, are you there?" + conversation_manager.add_user_message(test_message) + print(f"Sending message: {test_message}") + + # Get response from LLM + try: + response = llm_client.get_response(conversation_manager.get_history()) + print(f"Received response: {response}") + + # Add response to conversation + conversation_manager.add_assistant_message(response) + + print("\n✓ Message exchange successful!") + + except Exception as e: + print(f"✗ Error in message exchange: {e}") + + +if __name__ == "__main__": + test_message_exchange() \ No newline at end of file diff --git a/test_system.py b/test_system.py new file mode 100644 index 0000000..8c42e29 --- /dev/null +++ b/test_system.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Test script for the complete system workflow. +""" + +import sys +import os + +# Add the current directory to Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from config import Config +from llm_client import LLMClient +from interface import TextInterface +from conversation import ConversationManager + + +def test_system(): + """Test the complete system workflow.""" + print("Testing complete system workflow...") + print("=" * 40) + + try: + # Create all components + print("1. Initializing components...") + config = Config() + interface = TextInterface() + conversation_manager = ConversationManager() + llm_client = LLMClient(config) + print(" ✓ Components initialized") + + # Test connection + print("\n2. Testing connection to LM Studio...") + success = llm_client.test_connection() + if success: + print(" ✓ Connected to LM Studio") + else: + print(" ✗ Failed to connect to LM Studio") + return + + # Test message exchange + print("\n3. Testing message exchange...") + test_message = "Hello! Can you tell me what you are?" + conversation_manager.add_user_message(test_message) + print(f" Sending: {test_message}") + + response = llm_client.get_response(conversation_manager.get_history()) + conversation_manager.add_assistant_message(response) + print(f" Received: {response}") + print(" ✓ Message exchange successful") + + # Test conversation history + print("\n4. Testing conversation history...") + history = conversation_manager.get_history() + if len(history) == 2: + print(" ✓ Conversation history maintained") + else: + print(" ✗ Conversation history issue") + return + + # Test interface (simulated) + print("\n5. Testing interface components...") + interface.display_system_message("Interface test successful") + print(" ✓ Interface components working") + + print("\n" + "=" * 40) + print("✓ All tests passed! System is ready for use.") + + except Exception as e: + print(f"\n✗ Error during system test: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + test_system() \ No newline at end of file diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..7c44b67 --- /dev/null +++ b/web/app.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +FastAPI web frontend for the text-based LLM interaction system. +Serves a simple web UI and exposes an API to interact with the LLM. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Optional, Tuple +from uuid import uuid4 +from threading import Lock + +from fastapi import FastAPI, Request, Response, Cookie, HTTPException +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from config import Config +from llm_client import LLMClient +from conversation import ConversationManager +from game_config import load_game_config +from game_state import GameState + + +# Application setup +app = FastAPI(title="Text Adventure - Web UI") + +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR / "static" +STATIC_DIR.mkdir(parents=True, exist_ok=True) +SESSIONS_DIR = (BASE_DIR.parent / "data" / "sessions") +SESSIONS_DIR.mkdir(parents=True, exist_ok=True) + +# Mount /static for assets (requires 'aiofiles' in env for async file serving) +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +# Models +class ChatRequest(BaseModel): + message: str + + +# Globals +CONFIG = Config() +CLIENT = LLMClient(CONFIG) +GAMECFG = load_game_config() +_SESSIONS: dict[str, ConversationManager] = {} +_GAME_STATES: dict[str, GameState] = {} +_SESSIONS_LOCK = Lock() + +SESSION_COOKIE_NAME = "session_id" + + +def _get_or_create_session(session_id: Optional[str]) -> Tuple[str, ConversationManager, bool]: + """ + Return an existing conversation manager by session_id, + or create a new session if not present. + Returns (session_id, manager, created_flag). + """ + created = False + with _SESSIONS_LOCK: + if not session_id or session_id not in _SESSIONS: + session_id = uuid4().hex + cm = ConversationManager() + # Seed with system prompt if configured + if getattr(GAMECFG, "system_prompt", None): + cm.add_system_message(GAMECFG.system_prompt) + _SESSIONS[session_id] = cm + created = True + manager = _SESSIONS[session_id] + return session_id, manager, created + + +def _ensure_state(session_id: str) -> GameState: + """ + Ensure a GameState exists for a given session and return it. + Loads from disk if present, otherwise creates a new state and persists it. + """ + with _SESSIONS_LOCK: + if session_id not in _GAME_STATES: + path = SESSIONS_DIR / f"{session_id}.json" + gs = None + if path.exists(): + try: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) or {} + gs = GameState() + for field in [ + "room_description", + "door_description", + "door_locked", + "door_open", + "door_id", + "lock_id", + "lock_key_id", + "key_description", + "key_hidden", + "key_revealed", + "key_taken", + "key_id", + "container_id", + "inventory", + "exits", + "completed", + ]: + if field in data: + setattr(gs, field, data[field]) + except Exception: + gs = None + if gs is None: + gs = GameState.from_files("state") + try: + with path.open("w", encoding="utf-8") as f: + json.dump(gs.__dict__, f) + except Exception: + pass + _GAME_STATES[session_id] = gs + return _GAME_STATES[session_id] + + +# Routes +@app.get("/", response_class=FileResponse) +def index() -> FileResponse: + """ + Serve the main web UI. + """ + index_path = STATIC_DIR / "index.html" + if not index_path.exists(): + # Provide a minimal fallback page if index.html is missing + # This should not happen in normal usage. + fallback = """ + + Text Adventure +

Text Adventure Web UI

index.html is missing.

+ +""" + tmp_path = STATIC_DIR / "_fallback_index.html" + tmp_path.write_text(fallback, encoding="utf-8") + return FileResponse(str(tmp_path)) + return FileResponse(str(index_path)) + + +@app.get("/api/health") +def health() -> dict: + """ + Health check endpoint. + """ + return {"status": "ok"} + +@app.get("/api/session") +def session_info( + response: Response, + session_id: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME), +) -> JSONResponse: + """ + Ensure a session exists, set cookie if needed, and return scenario metadata and start message. + Also returns the current public game state snapshot. + """ + sid, conv, created = _get_or_create_session(session_id) + if created: + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=sid, + httponly=True, + samesite="lax", + max_age=7 * 24 * 3600, + path="/", + ) + gs = _ensure_state(sid) + payload = { + "session_id": sid, + "created": created, + "start_message": getattr(GAMECFG, "start_message", "") or "", + "scenario": getattr(GAMECFG, "scenario", {}) or {}, + "rules": getattr(GAMECFG, "rules", []) or [], + "state": gs.to_public_dict(), + } + return JSONResponse(payload) + + +@app.post("/api/chat") +def chat( + req: ChatRequest, + response: Response, + session_id: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME), +) -> JSONResponse: + """ + Accept a user message, apply deterministic game logic to update state, + then ask the LLM to narrate the outcome. Maintains a server-side session. + """ + message = req.message.strip() + if not message: + raise HTTPException(status_code=400, detail="Message cannot be empty") + + sid, conv, created = _get_or_create_session(session_id) + + # Set session cookie if new + if created: + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=sid, + httponly=True, + samesite="lax", + max_age=7 * 24 * 3600, # 7 days + path="/", + ) + + # Determine outcome via the game engine, then request narration + try: + gs = _ensure_state(sid) + conv.add_user_message(message) + + engine_outcome = gs.apply_action(message) # {"events": [...]} + + # Build a transient system message with canonical facts for the narrator + narrator_directive = { + "ENGINE_OUTCOME": { + "events": engine_outcome.get("events", []), + "state": gs.to_public_dict(), + }, + "NARRATION_RULES": [ + "Narrate strictly according to ENGINE_OUTCOME. Do not invent state.", + "Do not add items, unlock objects, or change inventory; the engine already did that.", + "Use 2-5 sentences, present tense, second person. Be concise and vivid.", + "If the action was impossible, explain why using the facts provided.", + ], + } + transient_system = { + "role": "system", + "content": "ENGINE CONTEXT (JSON): " + json.dumps(narrator_directive), + } + + messages = list(conv.get_history()) + [transient_system] + reply = CLIENT.get_response(messages) + + conv.add_assistant_message(reply) + + # Persist updated state + try: + with (SESSIONS_DIR / f"{sid}.json").open("w", encoding="utf-8") as f: + json.dump(gs.__dict__, f) + except Exception: + pass + + return JSONResponse({ + "reply": reply, + "completed": gs.completed, + "events": engine_outcome.get("events", []), + "state": gs.to_public_dict(), + }) + except Exception as e: + # Do not leak internal details in production; log as needed. + raise HTTPException(status_code=502, detail=f"LLM backend error: {e}") from e \ No newline at end of file diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..b4c4488 --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,115 @@ +(() => { + const el = { + statusDot: document.getElementById("statusDot"), + messages: document.getElementById("messages"), + form: document.getElementById("chatForm"), + input: document.getElementById("messageInput"), + sendBtn: document.getElementById("sendBtn"), + tplUser: document.getElementById("msg-user"), + tplAssistant: document.getElementById("msg-assistant"), + }; + + const state = { + sending: false, + }; + + function setStatus(ok) { + el.statusDot.classList.toggle("ok", !!ok); + el.statusDot.classList.toggle("err", !ok); + } + + async function healthCheck() { + try { + const res = await fetch("/api/health", { cache: "no-store" }); + setStatus(res.ok); + } catch { + setStatus(false); + } + } + + function appendMessage(role, text) { + const tpl = role === "user" ? el.tplUser : el.tplAssistant; + const node = tpl.content.cloneNode(true); + const bubble = node.querySelector(".bubble"); + bubble.textContent = text; + el.messages.appendChild(node); + el.messages.scrollTop = el.messages.scrollHeight; + } + + function setSending(sending) { + state.sending = sending; + el.input.disabled = sending; + el.sendBtn.disabled = sending; + el.sendBtn.textContent = sending ? "Sending..." : "Send"; + } + + async function sendMessage(text) { + setSending(true); + try { + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ message: text }), + }); + + if (!res.ok) { + let detail = ""; + try { + const data = await res.json(); + detail = data.detail || res.statusText; + } catch { + detail = res.statusText; + } + throw new Error(detail || `HTTP ${res.status}`); + } + + const data = await res.json(); + appendMessage("assistant", data.reply ?? ""); + setStatus(true); + } catch (err) { + appendMessage("assistant", `Error: ${err.message || err}`); + setStatus(false); + } finally { + setSending(false); + } + } + + el.form.addEventListener("submit", async (e) => { + e.preventDefault(); + const text = (el.input.value || "").trim(); + if (!text || state.sending) return; + appendMessage("user", text); + el.input.value = ""; + await sendMessage(text); + }); + + // Submit on Enter, allow Shift+Enter for newline (if we switch to textarea later) + el.input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + el.form.requestSubmit(); + } + }); + + // Initial status check + healthCheck(); + + // Initialize session and show start message if configured + (async function initSession() { + try { + const res = await fetch("/api/session", { credentials: "same-origin" }); + if (res.ok) { + const data = await res.json(); + if (data.start_message) { + appendMessage("assistant", data.start_message); + } + } + } catch (_) { + // no-op + } + })(); + + // Periodic health check + setInterval(healthCheck, 15000); +})(); \ No newline at end of file diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..af4a7cc --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,46 @@ + + + + + Text Adventure - Web UI + + + + +
+

Text Adventure

+
+
+ +
+
+
+
+ +
+ + +
+
+ + + + + + + + \ No newline at end of file diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..cfae460 --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,175 @@ +:root { + --bg: #0e1116; + --panel: #161b22; + --muted: #8b949e; + --text: #e6edf3; + --accent: #2f81f7; + --accent-2: #3fb950; + --danger: #f85149; + --bubble-user: #1f6feb22; + --bubble-assistant: #30363d; + --radius: 10px; + --shadow: 0 8px 24px rgba(0,0,0,0.25); +} + +* { box-sizing: border-box; } + +html, body { + height: 100%; +} + +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji"; + background: linear-gradient(180deg, #0e1116 0%, #0b0e13 100%); + color: var(--text); + display: flex; + flex-direction: column; +} + +.app-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: rgba(22,27,34,0.8); + backdrop-filter: blur(6px); + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid #21262d; +} + +.app-header h1 { + font-size: 16px; + margin: 0; + letter-spacing: 0.3px; + color: var(--text); + font-weight: 600; +} + +.status { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--muted); + box-shadow: 0 0 0 1px #21262d inset, 0 0 8px rgba(0,0,0,0.35); +} +.status.ok { background: var(--accent-2); box-shadow: 0 0 0 1px #2e7d32 inset, 0 0 16px rgba(63,185,80,0.6); } +.status.err { background: var(--danger); box-shadow: 0 0 0 1px #7f1d1d inset, 0 0 16px rgba(248,81,73,0.6); } + +.container { + width: 100%; + max-width: 900px; + margin: 18px auto 24px; + padding: 0 14px; + display: flex; + flex-direction: column; + gap: 12px; + flex: 1 1 auto; +} + +.chat { + background: var(--panel); + border: 1px solid #21262d; + border-radius: var(--radius); + min-height: 420px; + max-height: calc(100vh - 230px); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); +} + +.messages { + flex: 1 1 auto; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.msg { + display: flex; + align-items: flex-start; +} + +.msg-user { justify-content: flex-end; } +.msg-assistant { justify-content: flex-start; } + +.msg .bubble { + max-width: 78%; + padding: 10px 12px; + line-height: 1.35; + border-radius: 14px; + font-size: 14px; + border: 1px solid #30363d; + word-wrap: break-word; + word-break: break-word; + white-space: pre-wrap; +} + +.msg-user .bubble { + background: var(--bubble-user); + border-color: #1f6feb55; + color: var(--text); +} + +.msg-assistant .bubble { + background: var(--bubble-assistant); + border-color: #30363d; + color: var(--text); +} + +.input-row { + display: flex; + gap: 10px; + background: var(--panel); + border: 1px solid #21262d; + border-radius: var(--radius); + padding: 10px; + box-shadow: var(--shadow); +} + +.input-row input[type="text"] { + flex: 1 1 auto; + background: #0d1117; + color: var(--text); + border: 1px solid #30363d; + border-radius: 8px; + padding: 12px 12px; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.input-row input[type="text"]:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(47,129,247,0.25); +} + +.input-row button { + flex: 0 0 auto; + background: linear-gradient(180deg, #238636 0%, #2ea043 100%); + color: #fff; + border: 1px solid #2ea043; + border-radius: 8px; + padding: 0 16px; + font-weight: 600; + cursor: pointer; + min-width: 92px; + transition: transform 0.05s ease-in-out, filter 0.2s ease; +} + +.input-row button:hover { filter: brightness(1.05); } +.input-row button:active { transform: translateY(1px); } +.input-row button:disabled { + cursor: not-allowed; + opacity: 0.7; + filter: grayscale(0.2); +} + +@media (max-width: 640px) { + .messages { padding: 12px; } + .msg .bubble { max-width: 90%; } +} \ No newline at end of file