Initial commit: MUDlet AI Client with Telnet + Ollama integration
This commit is contained in:
commit
a1b7191098
22 files changed
+2197
No files matched your search
@@ -0,0 +1,108 @@
|
||||
# MUDlet AI Client
|
||||
|
||||
Un client MUD moderno con integrazione LLM per automazione del gameplay, sviluppato con Electron + React + TypeScript.
|
||||
|
||||
## Features
|
||||
|
||||
### Core
|
||||
- **Connessione Telnet** - Supporto completo per protocollo Telnet con parsing ANSI colors
|
||||
- **Game Window** - Display in tempo reale con input commands
|
||||
- **Connection Panel** - Gestione connessioni con host/porta configurabili
|
||||
|
||||
### Autoplayer LLM
|
||||
- **Ollama Integration** - Connessione a server Ollama remoto (ollama.solarpi.net)
|
||||
- **Decision Engine** - Sistema AI che analizza l'output del gioco e genera comandi
|
||||
- **System Prompt** - Personalizzazione del comportamento del bot
|
||||
- **Model Selection** - Supporto per modelli multipli (llama3.2, mistral, etc.)
|
||||
- **Action Queue** - Coda azioni per revisione manuale o auto-send
|
||||
|
||||
### UI/UX
|
||||
- **React + TypeScript** - Codice type-safe con React 18
|
||||
- **Tailwind CSS** - Styling moderno e responsive
|
||||
- **Navigation** - Pannelli Connection/Game/Autoplayer
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clona e installa dipendenze
|
||||
cd mudlet-ai-client
|
||||
npm install
|
||||
|
||||
# Avvia dev server (necessario per Electron)
|
||||
npm run dev
|
||||
|
||||
# Avvia Electron (in terminale separato)
|
||||
npm start
|
||||
```
|
||||
|
||||
## Connessione
|
||||
|
||||
Server LPMUD preconfigurato:
|
||||
- **Host**: 172.24.19.225
|
||||
- **Porta**: 6666
|
||||
|
||||
Server Ollama:
|
||||
- **URL**: http://ollama.solarpi.net
|
||||
|
||||
## Stack Tecnologico
|
||||
|
||||
| Layer | Tecnologie |
|
||||
|-------|------------|
|
||||
| Framework | Electron 28, React 18, TypeScript 5 |
|
||||
| UI | Tailwind CSS, shadcn/ui |
|
||||
| Telnet | telnet-client, ansicolor |
|
||||
| LLM | Ollama (HTTP API) |
|
||||
| Build | Vite 5, electron-vite |
|
||||
|
||||
## Struttura Progetto
|
||||
|
||||
```
|
||||
mudlet-ai-client/
|
||||
├── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── index.ts # App entry point, IPC handlers
|
||||
│ │ └── services/
|
||||
│ │ ├── TelnetService.ts # Connessione Telnet
|
||||
│ │ ├── OllamaService.ts # Client HTTP Ollama
|
||||
│ │ └── AutoplayerService.ts # Decision engine
|
||||
│ ├── renderer/ # React UI
|
||||
│ │ ├── App.tsx # Root component
|
||||
│ │ ├── main.tsx # Entry point
|
||||
│ │ └── components/
|
||||
│ │ ├── GameWindow.tsx # Game display + input
|
||||
│ │ ├── ConnectionPanel.tsx
|
||||
│ │ └── AutoplayerPanel.tsx
|
||||
│ └── preload/
|
||||
│ └── index.js # Context bridge
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Workflow Autoplayer
|
||||
|
||||
```
|
||||
1. OBSERVE → TelnetService riceve output
|
||||
2. PARSE → AutoplayerService analizza stato
|
||||
3. THINK → Ollama genera decisione
|
||||
4. ACT → Azione eseguita o messa in coda
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Fase 1: Client MUD Base
|
||||
- [x] Fase 2: Integrazione Ollama
|
||||
- [ ] Fase 3: Autoplayer miglioramenti (mapper 2D, trigger system)
|
||||
- [ ] Fase 4: Sistema Plugin
|
||||
- [ ] Fase 5: Funzionalità avanzate (persistence SQLite)
|
||||
|
||||
## Documentazione
|
||||
|
||||
- [Architettura](docs/ARCHITECTURE.md)
|
||||
- [Workflow di Sviluppo](docs/WORKFLOW.md)
|
||||
- [API IPC](docs/API.md)
|
||||
- [Piano Progetto](MUD_AUTOPLAYER_PLAN.md)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+373
@@ -0,0 +1,373 @@
|
||||
# API IPC Reference
|
||||
|
||||
## Context Bridge
|
||||
|
||||
Tutte le API sono esposte tramite `window.electronAPI`:
|
||||
|
||||
```typescript
|
||||
// TypeScript declaration
|
||||
interface ElectronAPI {
|
||||
// Telnet
|
||||
telnetConnect: (host: string, port: number) => Promise<boolean>;
|
||||
telnetDisconnect: () => Promise<void>;
|
||||
telnetSend: (data: string) => Promise<boolean>;
|
||||
telnetGetOutput: () => Promise<string>;
|
||||
onTelnetOutput: (callback: (data: string) => void) => void;
|
||||
|
||||
// Ollama
|
||||
ollamaChat: (messages: OllamaMessage[], options?: OllamaOptions) => Promise<OllamaResponse>;
|
||||
ollamaGetModels: () => Promise<string[]>;
|
||||
ollamaIsAvailable: () => Promise<boolean>;
|
||||
|
||||
// Autoplayer
|
||||
autoplayerConfigure: (config: AutoplayerConfig) => Promise<void>;
|
||||
autoplayerStart: () => Promise<void>;
|
||||
autoplayerStop: () => Promise<void>;
|
||||
autoplayerIsActive: () => Promise<boolean>;
|
||||
autoplayerExecuteAction: (action: string) => Promise<void>;
|
||||
onAutoplayerDecision: (callback: (decision: string) => void) => void;
|
||||
onAutoplayerAction: (callback: (action: string) => void) => void;
|
||||
onAutoplayerState: (callback: (state: GameState) => void) => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telnet API
|
||||
|
||||
### telnetConnect
|
||||
|
||||
Stabilisce connessione Telnet al server MUD.
|
||||
|
||||
```typescript
|
||||
const connected = await window.electronAPI.telnetConnect('172.24.19.225', 6666);
|
||||
```
|
||||
|
||||
**Params:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| host | string | Indirizzo server MUD |
|
||||
| port | number | Porta Telnet |
|
||||
|
||||
**Returns:** `Promise<boolean>` - true se connessione riuscita
|
||||
|
||||
**Errors:** Connection timeout, host unreachable
|
||||
|
||||
### telnetDisconnect
|
||||
|
||||
Chiude connessione Telnet.
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.telnetDisconnect();
|
||||
```
|
||||
|
||||
### telnetSend
|
||||
|
||||
Invia comando al server MUD.
|
||||
|
||||
```typescript
|
||||
const sent = await window.electronAPI.telnetSend('look\n');
|
||||
```
|
||||
|
||||
**Params:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| data | string | Comando da inviare (newline esclusa) |
|
||||
|
||||
**Returns:** `Promise<boolean>` - true se invio riuscito
|
||||
|
||||
### telnetGetOutput
|
||||
|
||||
Restituisce buffer output Telnet accumulato.
|
||||
|
||||
```typescript
|
||||
const output = await window.electronAPI.telnetGetOutput();
|
||||
```
|
||||
|
||||
**Returns:** `Promise<string>` - Output completo con HTML colors
|
||||
|
||||
### onTelnetOutput
|
||||
|
||||
Subscribe a nuovi output dal server.
|
||||
|
||||
```typescript
|
||||
window.electronAPI.onTelnetOutput((data) => {
|
||||
console.log('Nuovo output:', data);
|
||||
});
|
||||
```
|
||||
|
||||
**Callback Params:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| data | string | Chunk output HTML |
|
||||
|
||||
---
|
||||
|
||||
## Ollama API
|
||||
|
||||
### ollamaChat
|
||||
|
||||
Invia messaggi a Ollama e riceve risposta.
|
||||
|
||||
```typescript
|
||||
const response = await window.electronAPI.ollamaChat([
|
||||
{ role: 'system', content: 'You are a MUD player' },
|
||||
{ role: 'user', content: 'What should I do?' }
|
||||
], {
|
||||
model: 'llama3.2',
|
||||
temperature: 0.7
|
||||
});
|
||||
```
|
||||
|
||||
**Params:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| messages | OllamaMessage[] | Storico conversazione |
|
||||
| options | OllamaOptions | Opzioni (opzionali) |
|
||||
|
||||
**OllamaMessage:**
|
||||
```typescript
|
||||
{
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
**OllamaOptions:**
|
||||
```typescript
|
||||
{
|
||||
model?: string; // Default: 'llama3.2'
|
||||
temperature?: number; // Default: 0.7
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<OllamaResponse>`
|
||||
```typescript
|
||||
{
|
||||
model: string;
|
||||
response: string;
|
||||
done: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### ollamaGetModels
|
||||
|
||||
Lista modelli disponibili sul server.
|
||||
|
||||
```typescript
|
||||
const models = await window.electronAPI.ollamaGetModels();
|
||||
// ['llama3.2', 'llama3.1', 'mistral', ...]
|
||||
```
|
||||
|
||||
**Returns:** `Promise<string[]>` - Nomi modelli
|
||||
|
||||
### ollamaIsAvailable
|
||||
|
||||
Verifica se server Ollama raggiungibile.
|
||||
|
||||
```typescript
|
||||
const available = await window.electronAPI.ollamaIsAvailable();
|
||||
// true se server risponde
|
||||
```
|
||||
|
||||
**Returns:** `Promise<boolean>`
|
||||
|
||||
---
|
||||
|
||||
## Autoplayer API
|
||||
|
||||
### autoplayerConfigure
|
||||
|
||||
Configura comportamento autoplayer.
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.autoplayerConfigure({
|
||||
model: 'llama3.2',
|
||||
systemPrompt: 'You are an explorer...',
|
||||
temperature: 0.7,
|
||||
autoSend: true
|
||||
});
|
||||
```
|
||||
|
||||
**AutoplayerConfig:**
|
||||
```typescript
|
||||
{
|
||||
model: string; // Model LLM
|
||||
systemPrompt: string; // Personalità bot
|
||||
temperature?: number; // Creatività (0.0-1.0)
|
||||
autoSend?: boolean; // Auto-esegui azioni
|
||||
}
|
||||
```
|
||||
|
||||
### autoplayerStart
|
||||
|
||||
Avvia loop autoplayer.
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.autoplayerStart();
|
||||
```
|
||||
|
||||
### autoplayerStop
|
||||
|
||||
Ferma loop autoplayer.
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.autoplayerStop();
|
||||
```
|
||||
|
||||
### autoplayerIsActive
|
||||
|
||||
Verifica se autoplayer in esecuzione.
|
||||
|
||||
```typescript
|
||||
const active = await window.electronAPI.autoplayerIsActive();
|
||||
```
|
||||
|
||||
**Returns:** `Promise<boolean>`
|
||||
|
||||
### autoplayerExecuteAction
|
||||
|
||||
Esegui azione manualmente (senza passare per decision engine).
|
||||
|
||||
```typescript
|
||||
await window.electronAPI.autoplayerExecuteAction('go north');
|
||||
```
|
||||
|
||||
### onAutoplayerDecision
|
||||
|
||||
Subscribe a decisioni LLM.
|
||||
|
||||
```typescript
|
||||
window.electronAPI.onAutoplayerDecision((decision) => {
|
||||
console.log('LLM decision:', decision);
|
||||
});
|
||||
```
|
||||
|
||||
**Callback Params:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| decision | string | Risposta completa LLM |
|
||||
|
||||
### onAutoplayerAction
|
||||
|
||||
Subscribe ad azioni generate.
|
||||
|
||||
```typescript
|
||||
window.electronAPI.onAutoplayerAction((action) => {
|
||||
console.log('Action to execute:', action);
|
||||
});
|
||||
```
|
||||
|
||||
**Callback Params:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| action | string | Comando da eseguire |
|
||||
|
||||
### onAutoplayerState
|
||||
|
||||
Subscribe a updates stato gioco parsato.
|
||||
|
||||
```typescript
|
||||
window.electronAPI.onAutoplayerState((state) => {
|
||||
console.log('Current location:', state.location);
|
||||
console.log('Exits:', state.exits);
|
||||
});
|
||||
```
|
||||
|
||||
**GameState:**
|
||||
```typescript
|
||||
{
|
||||
location: string; // Nome location corrente
|
||||
description: string; // Descrizione stanza
|
||||
exits: string[]; // Uscite disponibili
|
||||
players: string[]; // Giocatori presenti
|
||||
objects: string[]; // Oggetti nella stanza
|
||||
health?: string; // HP corrente
|
||||
mana?: string; // Mana corrente
|
||||
prompt?: string; // Prompt del gioco
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Esempi Completi
|
||||
|
||||
### Connessione e Game Loop
|
||||
|
||||
```typescript
|
||||
// 1. Connetti
|
||||
const connected = await window.electronAPI.telnetConnect('172.24.19.225', 6666);
|
||||
if (!connected) {
|
||||
console.error('Connessione fallita');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Subscribe output
|
||||
window.electronAPI.onTelnetOutput((data) => {
|
||||
// Aggiorna UI
|
||||
setOutput(prev => prev + data);
|
||||
});
|
||||
|
||||
// 3. Invia comando
|
||||
await window.electronAPI.telnetSend('look\n');
|
||||
```
|
||||
|
||||
### Autoplayer Workflow
|
||||
|
||||
```typescript
|
||||
// 1. Configura
|
||||
await window.electronAPI.autoplayerConfigure({
|
||||
model: 'llama3.2',
|
||||
systemPrompt: 'You are a helpful MUD player. Explore and interact naturally.',
|
||||
temperature: 0.7,
|
||||
autoSend: false // Review manuale azioni
|
||||
});
|
||||
|
||||
// 2. Subscribe decisioni
|
||||
window.electronAPI.onAutoplayerAction((action) => {
|
||||
console.log('Pending action:', action);
|
||||
// Possibile review manuale qui
|
||||
});
|
||||
|
||||
// 3. Avvia
|
||||
await window.electronAPI.autoplayerStart();
|
||||
|
||||
// 4. Per fermare
|
||||
await window.electronAPI.autoplayerStop();
|
||||
```
|
||||
|
||||
### Ollama Chat Diretta
|
||||
|
||||
```typescript
|
||||
// Test chat con Ollama
|
||||
const response = await window.electronAPI.ollamaChat([
|
||||
{ role: 'system', content: 'You are a fantasy game master.' },
|
||||
{ role: 'user', content: 'Describe a dark forest.' }
|
||||
], { temperature: 0.8 });
|
||||
|
||||
console.log(response.response);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await window.electronAPI.telnetConnect(host, port);
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
}
|
||||
|
||||
// Event-based errors
|
||||
window.electronAPI.onTelnetOutput((data) => {
|
||||
// Gestisci errori in output
|
||||
});
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
- Tutte le chiamate IPC sono asincrone (Promise-based)
|
||||
- Gli eventi devono essere sottoscritti una volta (non in useEffect ripetuti)
|
||||
- Cleanup event listeners quando il componente unmount
|
||||
- Utilizzare `window.electronAPI` solo dopo il mount del componente
|
||||
@@ -0,0 +1,273 @@
|
||||
# Architettura
|
||||
|
||||
## Panoramica
|
||||
|
||||
MUDlet AI Client usa un'architettura a 3 layer basata su Electron:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ELECTRON MAIN │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │
|
||||
│ │ TelnetService│ │ OllamaService│ │ AutoplayerService│ │
|
||||
│ └──────┬──────┘ └──────┬──────┘ └────────┬────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └────────────────┼───────────────────┘ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ │ IPC Handlers │ │
|
||||
│ └─────────┬───────────┘ │
|
||||
└────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────────────────┐
|
||||
│ PRELOAD JS │
|
||||
│ contextBridge.exposeInMainWorld() │
|
||||
└────────────────────────┼────────────────────────────────┘
|
||||
│
|
||||
┌────────────────────────┼────────────────────────────────┐
|
||||
│ REACT RENDERER │
|
||||
│ ┌─────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
||||
│ │ App │ │ GameWindow │ │ AutoplayerPanel │ │
|
||||
│ └─────────┘ └─────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Layer 1: Electron Main Process
|
||||
|
||||
### Main Entry (`src/main/index.ts`)
|
||||
|
||||
Responsabilità:
|
||||
- Inizializzazione finestra BrowserWindow
|
||||
- Setup IPC handlers
|
||||
- Event forwarding tra servizi e renderer
|
||||
- Lifecycle management (app ready, window closed, etc.)
|
||||
|
||||
```typescript
|
||||
// Flusso dati Telnet
|
||||
telnetService.on('output', (data) => {
|
||||
mainWindow.webContents.send('telnet-output', data);
|
||||
autoplayerService.processOutput(data); // Forward all'autoplayer
|
||||
});
|
||||
```
|
||||
|
||||
### TelnetService (`src/main/services/TelnetService.ts`)
|
||||
|
||||
Wrapper attorno a `telnet-client` con funzionalità aggiuntive:
|
||||
|
||||
| Metodo | Descrizione |
|
||||
|--------|-------------|
|
||||
| `connect(host, port)` | Stabilisce connessione Telnet |
|
||||
| `send(data)` | Invia comando al server |
|
||||
| `getOutput()` | Restituisce buffer output |
|
||||
| `parseAnsiCodes(text)` | Converte ANSI escape codes in HTML |
|
||||
|
||||
**Eventi:**
|
||||
- `output` - Emesso quando arrives nuovo output
|
||||
- `connected` - Connessione stabilita
|
||||
- `disconnected` - Connessione chiusa
|
||||
- `error` - Errore di connessione
|
||||
|
||||
**Parsing ANSI:**
|
||||
```typescript
|
||||
// ansicolor converte sequenze ANSI in spans colorati
|
||||
// Ogni span viene convertito in <span style="...">
|
||||
```
|
||||
|
||||
### OllamaService (`src/main/services/OllamaService.ts`)
|
||||
|
||||
Client HTTP per Ollama API. Supporta:
|
||||
|
||||
| Metodo | Descrizione |
|
||||
|--------|-------------|
|
||||
| `chat(messages, options)` | Chat singola, attesa risposta |
|
||||
| `chatStream(messages, options)` | Streaming response token-by-token |
|
||||
| `getLocalModels()` | Lista modelli disponibili |
|
||||
| `isAvailable()` | Check server reachability |
|
||||
|
||||
**API Endpoint:**
|
||||
```
|
||||
POST http://ollama.solarpi.net/api/chat
|
||||
{
|
||||
"model": "llama3.2",
|
||||
"messages": [...],
|
||||
"stream": true,
|
||||
"options": {
|
||||
"temperature": 0.7,
|
||||
"num_predict": 512
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AutoplayerService (`src/main/services/AutoplayerService.ts`)
|
||||
|
||||
Decision engine che orchestrate il loop autoplayer:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AUTOPLAYER LOOP │
|
||||
│ │
|
||||
│ 1. OBSERVE ← processOutput(output) │
|
||||
│ 2. PARSE ← parseGameState() │
|
||||
│ 3. THINK ← ollamaService.chat() │
|
||||
│ 4. DECIDE ← extractAction() │
|
||||
│ 5. ACT ← executeAction() / queueAction() │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Configurazione:**
|
||||
```typescript
|
||||
interface AutoplayerConfig {
|
||||
model: string; // Model LLM da usare
|
||||
systemPrompt: string; // Comportamento del bot
|
||||
temperature: number; // Creatività (0.0-1.0)
|
||||
autoSend: boolean; // Auto-esegui azioni
|
||||
}
|
||||
```
|
||||
|
||||
**Game State Parser:**
|
||||
```typescript
|
||||
parseGameState(output: string): GameState {
|
||||
// Estrae: location, exits, players, objects, description
|
||||
}
|
||||
```
|
||||
|
||||
**Decision Prompt:**
|
||||
```
|
||||
Context: [ultimi 30 righe output]
|
||||
Sei in: [location]
|
||||
Uscite disponibili: north, south, east
|
||||
|
||||
Cosa fai? (rispondi SOLO con il comando)
|
||||
```
|
||||
|
||||
## Layer 2: Preload Script
|
||||
|
||||
espone API sicure al renderer tramite `contextBridge`:
|
||||
|
||||
```javascript
|
||||
// Telnet
|
||||
telnetConnect(host, port)
|
||||
telnetSend(data)
|
||||
onTelnetOutput(callback)
|
||||
|
||||
// Ollama
|
||||
ollamaChat(messages, options)
|
||||
ollamaGetModels()
|
||||
ollamaIsAvailable()
|
||||
|
||||
// Autoplayer
|
||||
autoplayerConfigure(config)
|
||||
autoplayerStart()
|
||||
autoplayerStop()
|
||||
autoplayerExecuteAction(action)
|
||||
onAutoplayerDecision(callback)
|
||||
onAutoplayerAction(callback)
|
||||
```
|
||||
|
||||
## Layer 3: React Renderer
|
||||
|
||||
### App (`src/renderer/App.tsx`)
|
||||
|
||||
Root component con navigation:
|
||||
|
||||
```typescript
|
||||
// Navigation state
|
||||
const [activePanel, setActivePanel] = useState<'connection' | 'game' | 'autoplayer'>();
|
||||
|
||||
// Connection status
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
```
|
||||
|
||||
### GameWindow (`src/renderer/components/GameWindow.tsx`)
|
||||
|
||||
Display principale del gioco:
|
||||
|
||||
| Componente | Funzione |
|
||||
|------------|----------|
|
||||
| Output div | Visualizza output HTML con colors |
|
||||
| Form | Input comandi + submit |
|
||||
| Auto-scroll | Mantiene focus su output recente |
|
||||
|
||||
```typescript
|
||||
// Subscribe a output Telnet
|
||||
useEffect(() => {
|
||||
window.electronAPI.onTelnetOutput((data) => {
|
||||
setOutput(prev => prev + data);
|
||||
});
|
||||
}, []);
|
||||
```
|
||||
|
||||
### AutoplayerPanel (`src/renderer/components/AutoplayerPanel.tsx`)
|
||||
|
||||
Pannello controllo autoplayer:
|
||||
|
||||
| Feature | UI |
|
||||
|---------|-----|
|
||||
| Toggle On/Off | Switch button |
|
||||
| Model selection | Dropdown (da Ollama) |
|
||||
| System prompt | Textarea modificabile |
|
||||
| Live status | Ultime decisioni/azioni |
|
||||
| Ollama availability | Check automatico |
|
||||
|
||||
## Flusso Dati
|
||||
|
||||
### Telnet → UI
|
||||
```
|
||||
1. Telnet server → telnet-client
|
||||
2. TelnetService → emits 'output'
|
||||
3. Main process → ipcRenderer.send('telnet-output')
|
||||
4. GameWindow → onTelnetOutput callback
|
||||
5. React state → setOutput()
|
||||
6. Render → HTML display
|
||||
```
|
||||
|
||||
### Autoplayer Decision
|
||||
```
|
||||
1. Output Telnet arrives
|
||||
2. AutoplayerService.processOutput()
|
||||
3. parseGameState() → GameState { location, exits, ... }
|
||||
4. shouldMakeDecision() → true/false
|
||||
5. ollamaService.chat(messages)
|
||||
6. extractAction() → "go north"
|
||||
7. autoSend ? executeAction() : queueAction()
|
||||
8. Events: 'decision', 'action'
|
||||
9. UI updates: lastDecision, lastAction
|
||||
```
|
||||
|
||||
## Configurazione
|
||||
|
||||
### Environment
|
||||
- Server Ollama: `http://ollama.solarpi.net` (hardcoded)
|
||||
- LPMUD default: `172.24.19.225:6666`
|
||||
|
||||
### Tipi Condivisi
|
||||
|
||||
```typescript
|
||||
// src/main/services/AutoplayerService.ts
|
||||
interface GameState {
|
||||
location: string;
|
||||
description: string;
|
||||
exits: string[];
|
||||
players: string[];
|
||||
objects: string[];
|
||||
health?: string;
|
||||
mana?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
interface OllamaMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Limitazioni Attuali
|
||||
|
||||
| Componente | Limitation | Piani |
|
||||
|------------|------------|-------|
|
||||
| ANSI Parsing | Solo colori basic | Parser completo |
|
||||
| Game Parser | Regex-based, fragile | ML parser |
|
||||
| Autoplayer | No memory persistent | SQLite storage |
|
||||
| Mapper | Non implementato | Fase 3 |
|
||||
| Triggers | Non implementati | Fase 3 |
|
||||
| Plugins | Non implementati | Fase 4 |
|
||||
@@ -0,0 +1,287 @@
|
||||
# Workflow di Sviluppo
|
||||
|
||||
## Setup Ambiente
|
||||
|
||||
### Prerequisiti
|
||||
- Node.js 18+
|
||||
- npm 9+
|
||||
- Git
|
||||
|
||||
### Installazione
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
cd mudlet-ai-client
|
||||
|
||||
# Installa dipendenze
|
||||
npm install
|
||||
|
||||
# Verifica installazione
|
||||
npm run dev # Vite dev server
|
||||
npm start # Electron app
|
||||
```
|
||||
|
||||
### Struttura Rami
|
||||
|
||||
```
|
||||
main
|
||||
├── develop # Branch di sviluppo attivo
|
||||
├── feature/* # Feature branches
|
||||
├── bugfix/* # Bug fix branches
|
||||
└── release/* # Release branches
|
||||
```
|
||||
|
||||
## Workflow Git
|
||||
|
||||
### Feature Development
|
||||
|
||||
```bash
|
||||
# Crea branch da develop
|
||||
git checkout develop
|
||||
git checkout -b feature/nome-feature
|
||||
|
||||
# Sviluppa e commit
|
||||
git add .
|
||||
git commit -m "feat: descrizione feature"
|
||||
|
||||
# Push e PR
|
||||
git push origin feature/nome-feature
|
||||
# Crea Pull Request su GitHub
|
||||
```
|
||||
|
||||
### Commit Convention
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
Types:
|
||||
- feat: Nuova feature
|
||||
- fix: Bug fix
|
||||
- docs: Documentazione
|
||||
- refactor: Code refactoring
|
||||
- test: Aggiunta test
|
||||
- chore: Manutenzione
|
||||
|
||||
Examples:
|
||||
feat(telnet): add ANSI color support
|
||||
fix(autoplayer): resolve action queue ordering
|
||||
docs(api): update IPC documentation
|
||||
```
|
||||
|
||||
## Build & Test
|
||||
|
||||
### Comandi NPM
|
||||
|
||||
| Comando | Descrizione |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Avvia Vite dev server (port 3000) |
|
||||
| `npm run build` | Build production (compila TS + Vite) |
|
||||
| `npm run preview` | Preview build production |
|
||||
| `npm start` | Avvia Electron app |
|
||||
|
||||
### Build Production
|
||||
|
||||
```bash
|
||||
# Build completo
|
||||
npm run build
|
||||
|
||||
# Output in dist/
|
||||
dist/
|
||||
├── renderer/ # Bundle React
|
||||
└── main/ # Bundle Electron main
|
||||
```
|
||||
|
||||
### Debug
|
||||
|
||||
```bash
|
||||
# DevTools aperto automaticamente
|
||||
npm start
|
||||
|
||||
# Per debug remoto:
|
||||
# 1. Apri DevTools (View → Toggle DevTools)
|
||||
# 2. Console: renderer process
|
||||
# 3. Terminale: main process (output console.log)
|
||||
```
|
||||
|
||||
## Sviluppo Componenti
|
||||
|
||||
### Aggiungere Nuovo Servizio Main Process
|
||||
|
||||
1. Crea file in `src/main/services/NomeService.ts`:
|
||||
|
||||
```typescript
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
interface NomeServiceEvents {
|
||||
'event': [data: Type];
|
||||
}
|
||||
|
||||
export class NomeService extends EventEmitter<NomeServiceEvents> {
|
||||
// Implementazione
|
||||
}
|
||||
```
|
||||
|
||||
2. Aggiungi in `src/main/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { NomeService } from './services/NomeService';
|
||||
|
||||
const nomeService = new NomeService();
|
||||
|
||||
// IPC handlers
|
||||
ipcMain.handle('nome-action', async (_, arg) => {
|
||||
return nomeService.action(arg);
|
||||
});
|
||||
|
||||
// Event forwarding
|
||||
nomeService.on('event', (data) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('nome-event', data);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
3. Aggiorna `src/preload/index.js`:
|
||||
|
||||
```javascript
|
||||
nomeAction: (arg) => ipcRenderer.invoke('nome-action', arg),
|
||||
onNomeEvent: (callback) => ipcRenderer.on('nome-event', (_, data) => callback(data)),
|
||||
```
|
||||
|
||||
### Aggiungere Componente UI
|
||||
|
||||
1. Crea file in `src/renderer/components/NomeComponente.tsx`:
|
||||
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
prop1: string;
|
||||
onAction: (data: Type) => void;
|
||||
}
|
||||
|
||||
export function NomeComponente({ prop1, onAction }: Props) {
|
||||
// Implementazione
|
||||
return <div>...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
2. Importa in `App.tsx`:
|
||||
|
||||
```typescript
|
||||
import { NomeComponente } from './components/NomeComponente';
|
||||
|
||||
// Usa nel render
|
||||
<NomeComponente prop1="value" onAction={handler} />
|
||||
```
|
||||
|
||||
## IPC Communication Pattern
|
||||
|
||||
### Request-Response (Renderer → Main)
|
||||
|
||||
```typescript
|
||||
// Renderer
|
||||
const result = await window.electronAPI.someAction(arg);
|
||||
|
||||
// Main
|
||||
ipcMain.handle('some-action', async (_, arg) => {
|
||||
return service.action(arg);
|
||||
});
|
||||
```
|
||||
|
||||
### Events (Main → Renderer)
|
||||
|
||||
```typescript
|
||||
// Renderer
|
||||
window.electronAPI.onSomeEvent((data) => {
|
||||
console.log(data);
|
||||
});
|
||||
|
||||
// Main
|
||||
service.on('event', (data) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('someEvent', data);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Two-Way Communication
|
||||
|
||||
```typescript
|
||||
// 1. Renderer invia richiesta
|
||||
const action = await window.electronAPI.getDecision(context);
|
||||
|
||||
// 2. Main processa
|
||||
ipcMain.handle('getDecision', async (_, context) => {
|
||||
return autoplayerService.decide(context);
|
||||
});
|
||||
|
||||
// 3. Risultato tornato al renderer
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Manuali
|
||||
|
||||
```bash
|
||||
# 1. Avvia dev server
|
||||
npm run dev
|
||||
|
||||
# 2. Avvia Electron (nuovo terminale)
|
||||
npm start
|
||||
|
||||
# 3. Testa connessione
|
||||
# - Host: 172.24.19.225
|
||||
# - Porta: 6666
|
||||
|
||||
# 4. Testa autoplayer
|
||||
# - Attiva toggle
|
||||
# - Verifica decisioni
|
||||
```
|
||||
|
||||
### Checklist Pre-Release
|
||||
|
||||
- [ ] TypeScript senza errori (`npm run build`)
|
||||
- [ ] Componenti renderizzano correttamente
|
||||
- [ ] Connessione Telnet funziona
|
||||
- [ ] Ollama reachable e risponde
|
||||
- [ ] Autoplayer genera azioni sensate
|
||||
- [ ] No memory leaks (DevTools → Memory)
|
||||
- [ ] UI responsive
|
||||
|
||||
## Deployment
|
||||
|
||||
### Electron Build
|
||||
|
||||
```bash
|
||||
# Installa electron-builder
|
||||
npm install electron-builder --save-dev
|
||||
|
||||
# Build per piattaforma corrente
|
||||
npm run build
|
||||
npx electron-builder
|
||||
|
||||
# Output in release/
|
||||
```
|
||||
|
||||
### Update MUD_AUTOPLAYER_PLAN.md
|
||||
|
||||
Dopo ogni fase completata:
|
||||
|
||||
```markdown
|
||||
### Fase N: Nome (DATA) - ✅ COMPLETATO
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
- [ ] Task 3 (futuro)
|
||||
```
|
||||
|
||||
## Risorse
|
||||
|
||||
| Risorsa | URL |
|
||||
|---------|-----|
|
||||
| Electron Docs | https://www.electronjs.org/docs |
|
||||
| React Docs | https://react.dev |
|
||||
| TypeScript | https://www.typescriptlang.org/docs |
|
||||
| Vite | https://vitejs.dev |
|
||||
| Tailwind | https://tailwindcss.com/docs |
|
||||
| Ollama API | https://github.com/ollama/ollama/blob/main/docs/api.md |
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "mudlet-ai-client",
|
||||
"version": "1.0.0",
|
||||
"description": "MUD Client with LLM Autoplayer",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"start": "electron ."
|
||||
},
|
||||
"dependencies": {
|
||||
"electron": "^28.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"telnet-client": "^2.0.3",
|
||||
"ansicolor": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/node": "^20.10.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"electron-vite": "^1.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"@vitejs/plugin-react": "^4.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import path from 'path';
|
||||
import { TelnetService } from './services/TelnetService';
|
||||
import { OllamaService } from './services/OllamaService';
|
||||
import { AutoplayerService } from './services/AutoplayerService';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const telnetService = new TelnetService();
|
||||
const ollamaService = new OllamaService();
|
||||
const autoplayerService = new AutoplayerService(ollamaService, telnetService);
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadURL('http://localhost:3000');
|
||||
mainWindow.webContents.openDevTools();
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('telnet-connect', async (_, host: string, port: number) => {
|
||||
return telnetService.connect(host, port);
|
||||
});
|
||||
|
||||
ipcMain.handle('telnet-disconnect', async () => {
|
||||
return telnetService.disconnect();
|
||||
});
|
||||
|
||||
ipcMain.handle('telnet-send', async (_, data: string) => {
|
||||
return telnetService.send(data);
|
||||
});
|
||||
|
||||
ipcMain.handle('telnet-get-output', async () => {
|
||||
return telnetService.getOutput();
|
||||
});
|
||||
|
||||
telnetService.on('output', (data: string) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('telnet-output', data);
|
||||
}
|
||||
autoplayerService.processOutput(data);
|
||||
});
|
||||
|
||||
ipcMain.handle('ollama-chat', async (_, messages, options) => {
|
||||
return ollamaService.chat(messages, options);
|
||||
});
|
||||
|
||||
ipcMain.handle('ollama-get-models', async () => {
|
||||
return ollamaService.getLocalModels();
|
||||
});
|
||||
|
||||
ipcMain.handle('ollama-is-available', async () => {
|
||||
return ollamaService.isAvailable();
|
||||
});
|
||||
|
||||
ipcMain.handle('autoplayer-configure', async (_, config) => {
|
||||
autoplayerService.configure(config);
|
||||
});
|
||||
|
||||
ipcMain.handle('autoplayer-start', async () => {
|
||||
autoplayerService.start();
|
||||
});
|
||||
|
||||
ipcMain.handle('autoplayer-stop', async () => {
|
||||
autoplayerService.stop();
|
||||
});
|
||||
|
||||
ipcMain.handle('autoplayer-is-active', async () => {
|
||||
return autoplayerService.isActive();
|
||||
});
|
||||
|
||||
ipcMain.handle('autoplayer-execute-action', async (_, action) => {
|
||||
autoplayerService.executeAction(action);
|
||||
});
|
||||
|
||||
autoplayerService.on('decision', (decision: string) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('autoplayer-decision', decision);
|
||||
}
|
||||
});
|
||||
|
||||
autoplayerService.on('action', (action: string) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('autoplayer-action', action);
|
||||
}
|
||||
});
|
||||
|
||||
autoplayerService.on('state-update', (state) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('autoplayer-state', state);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { OllamaService } from './OllamaService';
|
||||
import { TelnetService } from './TelnetService';
|
||||
|
||||
interface GameState {
|
||||
location: string;
|
||||
description: string;
|
||||
exits: string[];
|
||||
players: string[];
|
||||
objects: string[];
|
||||
health?: string;
|
||||
mana?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
interface AutoplayerConfig {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
temperature: number;
|
||||
autoSend: boolean;
|
||||
}
|
||||
|
||||
interface AutoplayerEvents {
|
||||
'state-update': [GameState];
|
||||
'action': [string];
|
||||
'decision': [string];
|
||||
'error': [Error];
|
||||
}
|
||||
|
||||
export class AutoplayerService extends EventEmitter<AutoplayerEvents> {
|
||||
private ollamaService: OllamaService;
|
||||
private telnetService: TelnetService;
|
||||
private config: AutoplayerConfig;
|
||||
private isRunning: boolean = false;
|
||||
private lastOutput: string = '';
|
||||
private conversationHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [];
|
||||
private actionQueue: string[] = [];
|
||||
|
||||
constructor(ollamaService: OllamaService, telnetService: TelnetService) {
|
||||
super();
|
||||
this.ollamaService = ollamaService;
|
||||
this.telnetService = telnetService;
|
||||
this.config = {
|
||||
model: 'llama3.2',
|
||||
systemPrompt: this.getDefaultSystemPrompt(),
|
||||
temperature: 0.7,
|
||||
autoSend: false,
|
||||
};
|
||||
}
|
||||
|
||||
private getDefaultSystemPrompt(): string {
|
||||
return `Sei un giocatore esperto di MUD (Multi-User Dungeon).
|
||||
Il tuo obiettivo è esplorare, interagire con l'ambiente e altri giocatori in modo naturale.
|
||||
|
||||
Regole:
|
||||
1. Analizza SEMPRE il contesto prima di agire
|
||||
2. I tuoi comandi devono essere coerenti con la situazione
|
||||
3. Rispondi in modo appropriato se qualcuno ti parla
|
||||
4. Non fare azioni ripetitive senza motivo
|
||||
5. Mantieni un comportamento naturale e coerente
|
||||
|
||||
Quando devi agire, rispondi SOLO con il comando da eseguire, niente altro.
|
||||
Per più comandi, separali con punto e virgola.
|
||||
Esempi: "look", "go north", "say Ciao a tutti!", "get sword"`;
|
||||
}
|
||||
|
||||
configure(config: Partial<AutoplayerConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.isRunning) return;
|
||||
this.isRunning = true;
|
||||
this.conversationHistory = [];
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.isRunning = false;
|
||||
this.ollamaService.abort();
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
processOutput(output: string): void {
|
||||
this.lastOutput = output;
|
||||
const gameState = this.parseGameState(output);
|
||||
|
||||
if (gameState.location) {
|
||||
this.emit('state-update', gameState);
|
||||
}
|
||||
|
||||
if (this.isRunning && this.shouldMakeDecision(output)) {
|
||||
this.makeDecision(gameState, output);
|
||||
}
|
||||
}
|
||||
|
||||
private parseGameState(output: string): GameState {
|
||||
const state: GameState = {
|
||||
location: '',
|
||||
description: '',
|
||||
exits: [],
|
||||
players: [],
|
||||
objects: [],
|
||||
};
|
||||
|
||||
const locationMatch = output.match(/(?:Sei in|Ti trovi in|In)\s+([^\n\r.]+)/i);
|
||||
if (locationMatch) {
|
||||
state.location = locationMatch[1].trim();
|
||||
}
|
||||
|
||||
const exitMatch = output.match(/Uscite?:?\s*([^\n\r]+)/i);
|
||||
if (exitMatch) {
|
||||
state.exits = exitMatch[1]
|
||||
.split(/[,;]/)
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter((e) => e);
|
||||
}
|
||||
|
||||
const playersMatch = output.match(>([^<]+)/g);
|
||||
if (playersMatch) {
|
||||
state.players = playersMatch.map((p) => p.replace('>', '').trim());
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private shouldMakeDecision(output: string): boolean {
|
||||
const promptPatterns = [
|
||||
/>\s*$/m,
|
||||
/Comando:\s*$/i,
|
||||
/Cosa vuoi fare\?/i,
|
||||
/\.\.\.\s*$/,
|
||||
];
|
||||
|
||||
return promptPatterns.some((pattern) => pattern.test(output)) ||
|
||||
output.includes('Dice qualcosa a gran voce:') ||
|
||||
output.length > 500;
|
||||
}
|
||||
|
||||
private async makeDecision(gameState: GameState, context: string): Promise<void> {
|
||||
const recentContext = this.getRecentContext(context);
|
||||
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: this.config.systemPrompt },
|
||||
...this.conversationHistory.slice(-10),
|
||||
{ role: 'user' as const, content: this.buildDecisionPrompt(gameState, recentContext) },
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await this.ollamaService.chat(messages, {
|
||||
model: this.config.model,
|
||||
temperature: this.config.temperature,
|
||||
});
|
||||
|
||||
const action = this.extractAction(response.response);
|
||||
this.emit('decision', response.response);
|
||||
this.emit('action', action);
|
||||
|
||||
this.conversationHistory.push(
|
||||
{ role: 'user', content: recentContext },
|
||||
{ role: 'assistant', content: action }
|
||||
);
|
||||
|
||||
if (this.config.autoSend) {
|
||||
this.executeAction(action);
|
||||
} else {
|
||||
this.actionQueue.push(action);
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('error', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private getRecentContext(context: string): string {
|
||||
const lines = context.split('\n').slice(-30);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private buildDecisionPrompt(gameState: GameState, context: string): string {
|
||||
let prompt = `Context:\n${context}\n\n`;
|
||||
|
||||
if (gameState.location) {
|
||||
prompt += `Sei in: ${gameState.location}\n`;
|
||||
}
|
||||
if (gameState.exits.length > 0) {
|
||||
prompt += `Uscite disponibili: ${gameState.exits.join(', ')}\n`;
|
||||
}
|
||||
|
||||
prompt += `\nCosa fai? (rispondi SOLO con il comando)`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private extractAction(response: string): string {
|
||||
const cleaned = response
|
||||
.replace(/^[\s\n]*(?:Azione:|Action:|Comando:)\s*/i, '')
|
||||
.replace(/\n.*/s, '')
|
||||
.trim();
|
||||
|
||||
const match = cleaned.match(/^["']?([^"';]+)["']?;?$/);
|
||||
return match ? match[1].trim() : cleaned;
|
||||
}
|
||||
|
||||
getNextAction(): string | null {
|
||||
return this.actionQueue.shift() || null;
|
||||
}
|
||||
|
||||
executeAction(action: string): void {
|
||||
this.telnetService.send(action + '\n');
|
||||
}
|
||||
|
||||
queueAction(action: string): void {
|
||||
this.actionQueue.push(action);
|
||||
}
|
||||
|
||||
clearActionQueue(): void {
|
||||
this.actionQueue = [];
|
||||
}
|
||||
|
||||
getPendingActions(): string[] {
|
||||
return [...this.actionQueue];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
interface OllamaMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface OllamaRequest {
|
||||
model: string;
|
||||
messages: OllamaMessage[];
|
||||
stream?: boolean;
|
||||
options?: {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
num_predict?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface OllamaResponse {
|
||||
model: string;
|
||||
response: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
interface OllamaServiceEvents {
|
||||
'stream': [string];
|
||||
'complete': [OllamaResponse];
|
||||
'error': [Error];
|
||||
}
|
||||
|
||||
export class OllamaService extends EventEmitter<OllamaServiceEvents> {
|
||||
private baseUrl: string = 'http://ollama.solarpi.net';
|
||||
private currentModel: string = 'llama3.2';
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = url;
|
||||
}
|
||||
|
||||
setModel(model: string): void {
|
||||
this.currentModel = model;
|
||||
}
|
||||
|
||||
getModels(): string[] {
|
||||
return [
|
||||
'llama3.2',
|
||||
'llama3.1',
|
||||
'mistral',
|
||||
'codellama',
|
||||
'llama3',
|
||||
'phi3',
|
||||
'gemma',
|
||||
];
|
||||
}
|
||||
|
||||
async chat(messages: OllamaMessage[], options?: { model?: string; temperature?: number }): Promise<OllamaResponse> {
|
||||
const model = options?.model || this.currentModel;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const request: OllamaRequest = {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
num_predict: 512,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const result: OllamaResponse = {
|
||||
model: data.model,
|
||||
response: data.message?.content || data.response || '',
|
||||
done: true,
|
||||
};
|
||||
|
||||
this.emit('complete', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.emit('error', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async *chatStream(messages: OllamaMessage[], options?: { model?: string }): AsyncGenerator<string> {
|
||||
const model = options?.model || this.currentModel;
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const request: OllamaRequest = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: this.abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.message?.content) {
|
||||
const chunk = data.message.content;
|
||||
this.emit('stream', chunk);
|
||||
yield chunk;
|
||||
}
|
||||
if (data.done) break;
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.abort();
|
||||
this.emit('error', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getLocalModels(): Promise<string[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const data = await response.json();
|
||||
return data.models?.map((m: { name: string }) => m.name.replace(':', '-')) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { Connection, ConnectionOptions } from 'telnet-client';
|
||||
import ansicolor from 'ansicolor';
|
||||
|
||||
interface TelnetServiceEvents {
|
||||
output: [string];
|
||||
connected: [];
|
||||
disconnected: [];
|
||||
error: [Error];
|
||||
}
|
||||
|
||||
export class TelnetService extends EventEmitter<TelnetServiceEvents> {
|
||||
private connection: Connection | null = null;
|
||||
private outputBuffer: string = '';
|
||||
private isConnected: boolean = false;
|
||||
|
||||
async connect(host: string, port: number): Promise<boolean> {
|
||||
try {
|
||||
this.connection = new Connection();
|
||||
|
||||
const options: ConnectionOptions = {
|
||||
host,
|
||||
port,
|
||||
timeout: 5000,
|
||||
shellPrompt: />|#|:/,
|
||||
crlf: '\r\n',
|
||||
recvBufferSize: 1024,
|
||||
};
|
||||
|
||||
await this.connection.connect(options);
|
||||
this.isConnected = true;
|
||||
|
||||
this.startOutputListener();
|
||||
|
||||
this.emit('connected');
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit('error', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.connection) {
|
||||
await this.connection.end();
|
||||
this.connection = null;
|
||||
this.isConnected = false;
|
||||
this.emit('disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
async send(data: string): Promise<boolean> {
|
||||
if (!this.connection || !this.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.connection.send(data, { timeout: 3000 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.emit('error', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getOutput(): string {
|
||||
return this.outputBuffer;
|
||||
}
|
||||
|
||||
clearOutput(): void {
|
||||
this.outputBuffer = '';
|
||||
}
|
||||
|
||||
private async startOutputListener(): Promise<void> {
|
||||
if (!this.connection) return;
|
||||
|
||||
const processOutput = async () => {
|
||||
try {
|
||||
const output = await this.connection!.receive();
|
||||
if (output) {
|
||||
const cleanedOutput = this.parseAnsiCodes(output);
|
||||
this.outputBuffer += cleanedOutput;
|
||||
this.emit('output', cleanedOutput);
|
||||
}
|
||||
} catch {
|
||||
if (this.isConnected) {
|
||||
setTimeout(processOutput, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processOutput();
|
||||
}
|
||||
|
||||
private parseAnsiCodes(text: string): string {
|
||||
const parsed = ansicolor.parse(text);
|
||||
|
||||
return parsed.spans
|
||||
.map((span) => {
|
||||
let result = span.text;
|
||||
|
||||
if (span.color) {
|
||||
if (typeof span.color === 'object') {
|
||||
if ('r' in span.color) {
|
||||
result = `<span style="color: rgb(${span.color.r},${span.color.g},${span.color.b})">${result}</span>`;
|
||||
} else {
|
||||
result = `<span style="color: ${span.color.name}">${result}</span>`;
|
||||
}
|
||||
} else {
|
||||
result = `<span style="color: ${span.color}">${result}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (span.bgColor) {
|
||||
const bgColor = typeof span.bgColor === 'object' && 'r' in span.bgColor
|
||||
? `rgb(${span.bgColor.r},${span.bgColor.g},${span.bgColor.b})`
|
||||
: span.bgColor.name;
|
||||
result = `<span style="background-color: ${bgColor}">${result}</span>`;
|
||||
}
|
||||
|
||||
if (span.css) {
|
||||
result = `<span style="${span.css}">${result}</span>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
telnetConnect: (host, port) => ipcRenderer.invoke('telnet-connect', host, port),
|
||||
telnetDisconnect: () => ipcRenderer.invoke('telnet-disconnect'),
|
||||
telnetSend: (data) => ipcRenderer.invoke('telnet-send', data),
|
||||
telnetGetOutput: () => ipcRenderer.invoke('telnet-get-output'),
|
||||
onTelnetOutput: (callback) => ipcRenderer.on('telnet-output', (_, data) => callback(data)),
|
||||
ollamaChat: (messages, options) => ipcRenderer.invoke('ollama-chat', messages, options),
|
||||
ollamaGetModels: () => ipcRenderer.invoke('ollama-get-models'),
|
||||
ollamaIsAvailable: () => ipcRenderer.invoke('ollama-is-available'),
|
||||
autoplayerConfigure: (config) => ipcRenderer.invoke('autoplayer-configure', config),
|
||||
autoplayerStart: () => ipcRenderer.invoke('autoplayer-start'),
|
||||
autoplayerStop: () => ipcRenderer.invoke('autoplayer-stop'),
|
||||
autoplayerIsActive: () => ipcRenderer.invoke('autoplayer-is-active'),
|
||||
autoplayerExecuteAction: (action) => ipcRenderer.invoke('autoplayer-execute-action', action),
|
||||
onAutoplayerDecision: (callback) => ipcRenderer.on('autoplayer-decision', (_, data) => callback(data)),
|
||||
onAutoplayerAction: (callback) => ipcRenderer.on('autoplayer-action', (_, data) => callback(data)),
|
||||
onAutoplayerState: (callback) => ipcRenderer.on('autoplayer-state', (_, data) => callback(data)),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { GameWindow } from './components/GameWindow';
|
||||
import { ConnectionPanel } from './components/ConnectionPanel';
|
||||
import { AutoplayerPanel } from './components/AutoplayerPanel';
|
||||
|
||||
export default function App() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [activePanel, setActivePanel] = useState<'connection' | 'game' | 'autoplayer'>('connection');
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-900 text-white">
|
||||
<header className="bg-gray-800 px-4 py-2 flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold">MUDlet AI Client</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setActivePanel('connection')}
|
||||
className={`px-3 py-1 rounded ${activePanel === 'connection' ? 'bg-blue-600' : 'bg-gray-700'}`}
|
||||
>
|
||||
Connection
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActivePanel('game')}
|
||||
className={`px-3 py-1 rounded ${activePanel === 'game' ? 'bg-blue-600' : 'bg-gray-700'}`}
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Game
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActivePanel('autoplayer')}
|
||||
className={`px-3 py-1 rounded ${activePanel === 'autoplayer' ? 'bg-blue-600' : 'bg-gray-700'}`}
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Autoplayer
|
||||
</button>
|
||||
</div>
|
||||
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
</header>
|
||||
|
||||
<main className="p-4">
|
||||
{activePanel === 'connection' && <ConnectionPanel onConnect={() => setIsConnected(true)} />}
|
||||
{activePanel === 'game' && <GameWindow />}
|
||||
{activePanel === 'autoplayer' && <AutoplayerPanel />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
export function AutoplayerPanel() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [model, setModel] = useState('llama3.2');
|
||||
const [models, setModels] = useState<string[]>(['llama3.2', 'llama3.1', 'mistral', 'codellama']);
|
||||
const [systemPrompt, setSystemPrompt] = useState(
|
||||
'Sei un giocatore esperto di MUD. Analizza sempre il contesto prima di agire. ' +
|
||||
'Rispondi in modo coerente con la situazione. I tuoi comandi devono essere realistici per un MUD fantasy.'
|
||||
);
|
||||
const [lastDecision, setLastDecision] = useState('');
|
||||
const [lastAction, setLastAction] = useState('');
|
||||
const [ollamaAvailable, setOllamaAvailable] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkOllamaAvailability();
|
||||
loadModels();
|
||||
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.onAutoplayerDecision((decision: string) => {
|
||||
setLastDecision(decision);
|
||||
});
|
||||
window.electronAPI.onAutoplayerAction((action: string) => {
|
||||
setLastAction(action);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkOllamaAvailability = async () => {
|
||||
if (window.electronAPI) {
|
||||
const available = await window.electronAPI.ollamaIsAvailable();
|
||||
setOllamaAvailable(available);
|
||||
}
|
||||
};
|
||||
|
||||
const loadModels = async () => {
|
||||
if (window.electronAPI) {
|
||||
const localModels = await window.electronAPI.ollamaGetModels();
|
||||
if (localModels.length > 0) {
|
||||
setModels(localModels);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = useCallback(async () => {
|
||||
if (!window.electronAPI) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (enabled) {
|
||||
await window.electronAPI.autoplayerStop();
|
||||
} else {
|
||||
await window.electronAPI.autoplayerConfigure({ model, systemPrompt });
|
||||
await window.electronAPI.autoplayerStart();
|
||||
}
|
||||
setEnabled(!enabled);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [enabled, model, systemPrompt]);
|
||||
|
||||
const handleModelChange = async (newModel: string) => {
|
||||
setModel(newModel);
|
||||
if (enabled && window.electronAPI) {
|
||||
await window.electronAPI.autoplayerConfigure({ model: newModel });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptChange = async (newPrompt: string) => {
|
||||
setSystemPrompt(newPrompt);
|
||||
if (enabled && window.electronAPI) {
|
||||
await window.electronAPI.autoplayerConfigure({ systemPrompt: newPrompt });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<h2 className="text-xl font-bold mb-4">Autoplayer Settings</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">Autoplayer Attivo</span>
|
||||
{!ollamaAvailable && (
|
||||
<p className="text-xs text-yellow-500 mt-1">Ollama non disponibile</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={loading || !ollamaAvailable}
|
||||
className={`w-14 h-8 rounded-full transition-colors ${enabled ? 'bg-green-500' : 'bg-gray-600'} disabled:opacity-50`}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 bg-white rounded-full transition-transform ${enabled ? 'translate-x-7' : 'translate-x-1'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Modello LLM</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => handleModelChange(e.target.value)}
|
||||
disabled={!ollamaAvailable}
|
||||
className="w-full bg-gray-700 px-4 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{models.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">System Prompt</label>
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => handlePromptChange(e.target.value)}
|
||||
rows={4}
|
||||
disabled={!ollamaAvailable}
|
||||
className="w-full bg-gray-700 px-4 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none resize-none disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-700 rounded">
|
||||
<h3 className="font-bold mb-2">Stato</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
{enabled ? 'Autoplayer attivo - in attesa di decisioni...' : 'Autoplayer disattivato'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{lastAction && (
|
||||
<div className="p-4 bg-blue-900/50 rounded border border-blue-700">
|
||||
<h3 className="font-bold text-blue-300 mb-1">Ultima Azione</h3>
|
||||
<code className="text-sm">{lastAction}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastDecision && (
|
||||
<div className="p-4 bg-purple-900/50 rounded border border-purple-700">
|
||||
<h3 className="font-bold text-purple-300 mb-1">Ultima Decisione LLM</h3>
|
||||
<p className="text-sm text-gray-300">{lastDecision}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ConnectionPanelProps {
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
export function ConnectionPanel({ onConnect }: ConnectionPanelProps) {
|
||||
const [host, setHost] = useState('172.24.19.225');
|
||||
const [port, setPort] = useState('6666');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleConnect = async () => {
|
||||
setConnecting(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await window.electronAPI.telnetConnect(host, parseInt(port));
|
||||
if (success) {
|
||||
onConnect();
|
||||
} else {
|
||||
setError('Connessione fallita');
|
||||
}
|
||||
} catch {
|
||||
setError('Errore di connessione');
|
||||
} finally {
|
||||
setConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-10 p-6 bg-gray-800 rounded-lg border border-gray-700">
|
||||
<h2 className="text-xl font-bold mb-4">Connessione MUD</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Host</label>
|
||||
<input
|
||||
type="text"
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
className="w-full bg-gray-700 px-4 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="es. localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm mb-1">Porta</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="w-full bg-gray-700 px-4 py-2 rounded border border-gray-600 focus:border-blue-500 focus:outline-none"
|
||||
placeholder="es. 23"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-400 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting}
|
||||
className="w-full bg-green-600 py-2 rounded hover:bg-green-700 transition-colors disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||
>
|
||||
{connecting ? 'Connessione...' : 'Connetti'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface GameWindowProps {}
|
||||
|
||||
export function GameWindow({}: GameWindowProps) {
|
||||
const [output, setOutput] = useState('');
|
||||
const [input, setInput] = useState('');
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.onTelnetOutput((data: string) => {
|
||||
setOutput((prev) => prev + data);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (outputRef.current) {
|
||||
outputRef.current.scrollTop = outputRef.current.scrollHeight;
|
||||
}
|
||||
}, [output]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || !window.electronAPI) return;
|
||||
|
||||
await window.electronAPI.telnetSend(input + '\n');
|
||||
setInput('');
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-120px)] bg-gray-900 rounded-lg overflow-hidden border border-gray-700">
|
||||
<div
|
||||
ref={outputRef}
|
||||
className="flex-1 p-4 overflow-auto font-mono text-sm whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: output || 'Connessione non attiva...' }}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 p-2 bg-gray-800 border-t border-gray-700">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Inserisci comando..."
|
||||
className="flex-1 bg-gray-700 text-white px-4 py-2 rounded border border-gray-600 focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-blue-600 px-6 py-2 rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #111827;
|
||||
color: #ffffff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #6b7280;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MUDlet AI Client</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
root: './src/renderer',
|
||||
publicDir: '../public',
|
||||
build: {
|
||||
outDir: '../../dist/renderer',
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user