Initial commit: MUDlet AI Client with Telnet + Ollama integration

This commit is contained in:
truetype committed 2026-02-10 10:39:18 +01:00
commit a1b7191098
22 files changed
+2197

No files matched your search

+112
View File
@@ -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);
}
});
+224
View File
@@ -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];
}
}
+187
View File
@@ -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 [];
}
}
}
+129
View File
@@ -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('');
}
}
+21
View File
@@ -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)),
});
+46
View File
@@ -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>
);
}
+148
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+32
View File
@@ -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;
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MUDlet AI Client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>
+10
View File
@@ -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>
);