Initial commit.
Basic docker deployment with Local LLM integration and simple game state.
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
PORTAINER_URL=http://10.0.0.199:9000
|
||||
PORTAINER_USERNAME=yourusername
|
||||
PORTAINER_PASSWORD=yourpassword
|
||||
PORTAINER_ENDPOINT=yourendpoint # Optional
|
||||
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal file
@@ -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/
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -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"]
|
||||
107
README.md
Normal file
107
README.md
Normal file
@@ -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.
|
||||
89
architecture.md
Normal file
89
architecture.md
Normal file
@@ -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
|
||||
32
config.py
Normal file
32
config.py
Normal file
@@ -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
|
||||
68
config/game_config.yaml
Normal file
68
config/game_config.yaml
Normal file
@@ -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.
|
||||
|
||||
79
conversation.py
Normal file
79
conversation.py
Normal file
@@ -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
|
||||
271
deploy_to_portainer.py
Normal file
271
deploy_to_portainer.py
Normal file
@@ -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()
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -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"
|
||||
60
game_config.py
Normal file
60
game_config.py
Normal file
@@ -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()
|
||||
285
game_state.py
Normal file
285
game_state.py
Normal file
@@ -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}
|
||||
44
interface.py
Normal file
44
interface.py
Normal file
@@ -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()
|
||||
87
llm_client.py
Normal file
87
llm_client.py
Normal file
@@ -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
|
||||
95
main.py
Normal file
95
main.py
Normal file
@@ -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()
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -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
|
||||
283
setup_guide.md
Normal file
283
setup_guide.md
Normal file
@@ -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.
|
||||
12
state/containers.json
Normal file
12
state/containers.json
Normal file
@@ -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]
|
||||
}
|
||||
10
state/exits.json
Normal file
10
state/exits.json
Normal file
@@ -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"
|
||||
}
|
||||
8
state/items.json
Normal file
8
state/items.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": 1,
|
||||
"type": "key",
|
||||
"description": "A brass key with a tarnished surface.",
|
||||
"hidden": true,
|
||||
"revealed": false,
|
||||
"taken": false
|
||||
}
|
||||
9
state/locks.json
Normal file
9
state/locks.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": 1,
|
||||
"type": "door_lock",
|
||||
"description": "A simple wooden door lock.",
|
||||
"locked": true,
|
||||
"openable": true,
|
||||
"open": false,
|
||||
"key_id": 1
|
||||
}
|
||||
10
state/room.json
Normal file
10
state/room.json
Normal file
@@ -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]
|
||||
}
|
||||
38
test_interface.py
Normal file
38
test_interface.py
Normal file
@@ -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()
|
||||
38
test_llm_connection.py
Normal file
38
test_llm_connection.py
Normal file
@@ -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()
|
||||
53
test_llm_exchange.py
Normal file
53
test_llm_exchange.py
Normal file
@@ -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()
|
||||
76
test_system.py
Normal file
76
test_system.py
Normal file
@@ -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()
|
||||
255
web/app.py
Normal file
255
web/app.py
Normal file
@@ -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 = """<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Text Adventure</title></head>
|
||||
<body><h1>Text Adventure Web UI</h1><p>index.html is missing.</p></body>
|
||||
</html>
|
||||
"""
|
||||
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
|
||||
115
web/static/app.js
Normal file
115
web/static/app.js
Normal file
@@ -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);
|
||||
})();
|
||||
46
web/static/index.html
Normal file
46
web/static/index.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Text Adventure - Web UI</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<h1>Text Adventure</h1>
|
||||
<div class="status" id="statusDot" title="Backend status"></div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section id="chat" class="chat">
|
||||
<div id="messages" class="messages" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
<form id="chatForm" class="input-row" autocomplete="off">
|
||||
<input
|
||||
id="messageInput"
|
||||
type="text"
|
||||
placeholder="Type your message..."
|
||||
aria-label="Message"
|
||||
required
|
||||
/>
|
||||
<button id="sendBtn" type="submit">Send</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<template id="msg-user">
|
||||
<div class="msg msg-user">
|
||||
<div class="bubble"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="msg-assistant">
|
||||
<div class="msg msg-assistant">
|
||||
<div class="bubble"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
175
web/static/styles.css
Normal file
175
web/static/styles.css
Normal file
@@ -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%; }
|
||||
}
|
||||
Reference in New Issue
Block a user