// GM Assistant - Claude-powered assistant for Foundry VTT // Uses ApplicationV2 for Foundry v13 compatibility const MODULE_ID = 'gm-assistant'; const BRIDGE_URL_DEFAULT = 'http://10.4.2.37:3001'; // Register module settings Hooks.once('init', () => { game.settings.register(MODULE_ID, 'bridgeUrl', { name: 'Bridge URL', hint: 'URL of the GM Assistant bridge service', scope: 'world', config: true, type: String, default: BRIDGE_URL_DEFAULT }); }); // Add control button when ready Hooks.once('ready', () => { if (!game.user?.isGM) return; console.log('GM Assistant | Module ready'); }); // Add button to scene controls Hooks.on('getSceneControlButtons', (controls) => { if (!game.user?.isGM) return; const tokenControls = controls.find(c => c.name === 'token'); if (tokenControls) { tokenControls.tools.push({ name: 'gm-assistant', title: 'GM Assistant', icon: 'fas fa-robot', button: true, onClick: () => GMAssistantApp.show() }); } }); // GM Assistant Application Window class GMAssistantApp extends foundry.applications.api.ApplicationV2 { static DEFAULT_OPTIONS = { id: 'gm-assistant-app', classes: ['gm-assistant-window'], tag: 'div', window: { title: 'GM Assistant', icon: 'fas fa-robot', resizable: true, minimizable: true }, position: { width: 450, height: 500 } }; static PARTS = { main: { template: null // We'll build the HTML directly } }; // Singleton instance static _instance = null; static show() { if (!this._instance) { this._instance = new GMAssistantApp(); } this._instance.render(true); return this._instance; } constructor(options = {}) { super(options); this.messages = []; this.sessionId = null; this.isStreaming = false; this.currentResponse = ''; } get bridgeUrl() { return game.settings.get(MODULE_ID, 'bridgeUrl'); } async _prepareContext(options) { return { messages: this.messages, isStreaming: this.isStreaming }; } async _renderHTML(context, options) { const html = document.createElement('div'); html.classList.add('gm-assistant-container'); html.innerHTML = `
Ready
`; return html; } _onRender(context, options) { const html = this.element; // Get elements const messagesEl = html.querySelector('[data-messages]'); const inputEl = html.querySelector('[data-input]'); const sendBtn = html.querySelector('[data-send]'); const newChatBtn = html.querySelector('[data-new-chat]'); const statusEl = html.querySelector('[data-status]'); // Store refs this._messagesEl = messagesEl; this._inputEl = inputEl; this._statusEl = statusEl; // Render existing messages this._renderMessages(); // Send button sendBtn.addEventListener('click', () => this._sendMessage()); // Enter to send (Shift+Enter for newline) inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._sendMessage(); } }); // New chat button newChatBtn.addEventListener('click', () => { this.messages = []; this.sessionId = null; this._renderMessages(); this._setStatus('New conversation started', 'connected'); }); // Check bridge health this._checkBridgeHealth(); } async _checkBridgeHealth() { try { const response = await fetch(`${this.bridgeUrl}/api/health`); if (response.ok) { this._setStatus('Connected to bridge', 'connected'); } else { this._setStatus('Bridge error', 'error'); } } catch (e) { this._setStatus('Cannot connect to bridge', 'error'); } } _setStatus(text, type = '') { if (this._statusEl) { this._statusEl.textContent = text; this._statusEl.className = 'gm-assistant-status ' + type; } } _renderMessages() { if (!this._messagesEl) return; this._messagesEl.innerHTML = this.messages.map(msg => { const classes = ['gm-assistant-message', msg.type]; return `
${this._escapeHtml(msg.content)}
`; }).join(''); // Scroll to bottom this._messagesEl.scrollTop = this._messagesEl.scrollHeight; } _escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } _addMessage(type, content) { this.messages.push({ type, content }); this._renderMessages(); } _updateLastAssistantMessage(content) { const last = this.messages[this.messages.length - 1]; if (last && last.type === 'assistant') { last.content = content; this._renderMessages(); } } async _sendMessage() { const input = this._inputEl; const message = input.value.trim(); if (!message || this.isStreaming) return; // Add user message this._addMessage('user', message); input.value = ''; // Start streaming this.isStreaming = true; this.currentResponse = ''; this._setStatus('Thinking...', ''); try { const response = await fetch(`${this.bridgeUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message, sessionId: this.sessionId }) }); if (!response.ok) { throw new Error(`Bridge error: ${response.status}`); } // Add placeholder for assistant response this._addMessage('assistant', ''); // Read SSE stream const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const data = JSON.parse(line.slice(6)); switch (data.type) { case 'session': this.sessionId = data.sessionId; break; case 'text': this.currentResponse += data.content; this._updateLastAssistantMessage(this.currentResponse); break; case 'tool': this._addMessage('tool', `Using: ${data.name}`); // Re-add assistant placeholder after tool message this._addMessage('assistant', this.currentResponse); break; case 'done': this.sessionId = data.sessionId; this._setStatus('Ready', 'connected'); break; case 'error': this._addMessage('system', `Error: ${data.message}`); break; } } catch (e) { // Skip invalid JSON lines } } } } catch (e) { console.error('GM Assistant error:', e); this._addMessage('system', `Error: ${e.message}`); this._setStatus('Error', 'error'); } finally { this.isStreaming = false; this._setStatus('Ready', 'connected'); } } } // Export for global access globalThis.GMAssistantApp = GMAssistantApp;