// 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 (v13 API) Hooks.on('getSceneControlButtons', (controls) => { if (!game.user?.isGM) return; // v13 uses array of control groups 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() }); } }); // Add floating button as fallback (always visible, draggable) Hooks.once('ready', () => { if (!game.user?.isGM) return; // Load saved position or use default const savedPos = JSON.parse(localStorage.getItem('gm-assistant-btn-pos') || '{"top":8,"left":240}'); const btn = document.createElement('button'); btn.id = 'gm-assistant-fab'; btn.innerHTML = ''; btn.title = 'GM Assistant (drag to move)'; btn.style.cssText = ` position: fixed; top: ${savedPos.top}px; left: ${savedPos.left}px; width: 36px; height: 36px; border-radius: 6px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; font-size: 16px; cursor: grab; z-index: 1000; box-shadow: 0 2px 6px rgba(0,0,0,0.3); user-select: none; `; let isDragging = false; let wasDragged = false; let startX, startY, startLeft, startTop; btn.onmousedown = (e) => { isDragging = true; wasDragged = false; startX = e.clientX; startY = e.clientY; startLeft = btn.offsetLeft; startTop = btn.offsetTop; btn.style.cursor = 'grabbing'; e.preventDefault(); }; document.addEventListener('mousemove', (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) wasDragged = true; btn.style.left = (startLeft + dx) + 'px'; btn.style.top = (startTop + dy) + 'px'; }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; btn.style.cursor = 'grab'; // Save position localStorage.setItem('gm-assistant-btn-pos', JSON.stringify({ top: btn.offsetTop, left: btn.offsetLeft })); } }); btn.onclick = (e) => { if (!wasDragged) GMAssistantApp.show(); }; document.body.appendChild(btn); console.log('GM Assistant | Floating button added'); }); // 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; } _replaceHTML(result, content, options) { content.replaceChildren(result); } _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 stopBtn = html.querySelector('[data-stop]'); const newChatBtn = html.querySelector('[data-new-chat]'); const copyBtn = html.querySelector('[data-copy]'); const saveBtn = html.querySelector('[data-save]'); const clearBtn = html.querySelector('[data-clear]'); const statusEl = html.querySelector('[data-status]'); // Store refs this._messagesEl = messagesEl; this._inputEl = inputEl; this._statusEl = statusEl; this._sendBtn = sendBtn; this._stopBtn = stopBtn; // Render existing messages this._renderMessages(); // Send button sendBtn.addEventListener('click', () => this._sendMessage()); // Stop button stopBtn.addEventListener('click', () => { if (this._abortController) { this._abortController.abort(); this._abortController = null; this.isStreaming = false; this._toggleButtons(false); this._setStatus('Stopped', 'error'); } }); // Enter to send (Shift+Enter for newline) inputEl.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._sendMessage(); } }); // New chat button (resets session and aborts any running request) newChatBtn.addEventListener('click', () => { if (this._abortController) { this._abortController.abort(); this._abortController = null; } this.messages = []; this.sessionId = null; this.isStreaming = false; this._renderMessages(); this._setStatus('New conversation started', 'connected'); }); // Copy button - copy chat log to clipboard copyBtn.addEventListener('click', () => { const log = this.messages.map(msg => `[${msg.type}] ${msg.content}`).join('\n\n'); this._fallbackCopy(log); }); // Save button - save chat to Foundry journal saveBtn.addEventListener('click', () => this._saveToJournal()); // Clear button - clear display only (keeps session) clearBtn.addEventListener('click', () => { this.messages = []; this._renderMessages(); this._setStatus('Chat cleared', '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; } } _toggleButtons(streaming) { if (this._sendBtn) this._sendBtn.style.display = streaming ? 'none' : ''; if (this._stopBtn) this._stopBtn.style.display = streaming ? '' : 'none'; } _renderMessages() { if (!this._messagesEl) return; this._messagesEl.innerHTML = this.messages.map(msg => { const classes = ['gm-assistant-message', msg.type]; // Render markdown for assistant messages, escape for user/system const content = msg.type === 'assistant' ? this._renderMarkdown(msg.content) : this._escapeHtml(msg.content); return `
${content}
`; }).join(''); // Scroll to bottom this._messagesEl.scrollTop = this._messagesEl.scrollHeight; } _escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } _renderMarkdown(text) { if (!text) return ''; // Render markdown while preserving existing HTML from Claude return text // Headers (only if not already HTML) .replace(/^### (.+)$/gm, '

$1

') .replace(/^## (.+)$/gm, '

$1

') .replace(/^# (.+)$/gm, '

$1

') // Bold and italic .replace(/\*\*\*(.+?)\*\*\*/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') .replace(/\*([^*]+?)\*/g, '$1') // Code blocks (fenced) .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') // Inline code (but not if already in HTML tags) .replace(/`([^`]+)`/g, '$1') // Blockquotes .replace(/^> (.+)$/gm, '
$1
') // Horizontal rules .replace(/^---$/gm, '
') // Unordered lists .replace(/^\* (.+)$/gm, '
  • $1
  • ') .replace(/^- (.+)$/gm, '
  • $1
  • ') // Wrap consecutive li elements in ul .replace(/(
  • [\s\S]*?<\/li>)(\s*
  • )/g, '$1$2') .replace(/(
  • [\s\S]*?<\/li>)(?!\s*
  • )/g, '') // Double newlines to paragraphs, single to br .replace(/\n\n/g, '

    ') .replace(/\n/g, '
    '); } _fallbackCopy(text) { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); try { document.execCommand('copy'); this._setStatus('Copied to clipboard', 'connected'); } catch (err) { this._setStatus('Copy failed - select manually', 'error'); console.log('Chat log:\n', text); } document.body.removeChild(textarea); } async _saveToJournal() { if (this.messages.length === 0) { this._setStatus('No messages to save', 'error'); return; } this._setStatus('Saving to journal...', ''); try { // Find or create the GM Assistant Logs folder let folder = game.folders.find(f => f.name === 'GM Assistant Logs' && f.type === 'JournalEntry'); if (!folder) { folder = await Folder.create({ name: 'GM Assistant Logs', type: 'JournalEntry', color: '#667eea' }); } // Generate timestamp for title const now = new Date(); const timestamp = now.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }); // Build HTML content from messages let htmlContent = '

    '; for (const msg of this.messages) { const label = msg.type.charAt(0).toUpperCase() + msg.type.slice(1); const cssClass = msg.type; // For assistant messages, content may already have HTML from markdown rendering const content = msg.type === 'assistant' ? this._renderMarkdown(msg.content) : this._escapeHtml(msg.content); htmlContent += `
    `; htmlContent += `[${label}]
    `; htmlContent += content; htmlContent += '

    '; } htmlContent += '
    '; // Create journal entry const journal = await JournalEntry.create({ name: `GM Assistant - ${timestamp}`, folder: folder.id, ownership: { default: 0 } // GM only }); // Add content page await journal.createEmbeddedDocuments('JournalEntryPage', [{ name: 'Chat Log', type: 'text', text: { content: htmlContent } }]); this._setStatus(`Saved to "${journal.name}"`, 'connected'); ui.notifications.info(`Chat saved to journal: ${journal.name}`); } catch (err) { console.error('GM Assistant save error:', err); this._setStatus('Save failed - see console', 'error'); ui.notifications.error('Failed to save chat log'); } } _addMessage(type, content) { this.messages.push({ type, content }); this._renderMessages(); } _updateLastAssistantMessage(content) { // Find the last assistant message (may not be the very last message if tools were used) for (let i = this.messages.length - 1; i >= 0; i--) { if (this.messages[i].type === 'assistant') { this.messages[i].content = content; this._renderMessages(); return; } } // No assistant message found, add one this._addMessage('assistant', content); } 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...', ''); this._toggleButtons(true); // Create abort controller for this request this._abortController = new AbortController(); try { const response = await fetch(`${this.bridgeUrl}/api/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message, sessionId: this.sessionId }), signal: this._abortController.signal }); 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': // Show tool use in status line instead of cluttering chat this._setStatus(`Using: ${data.name}`, 'working'); 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) { if (e.name === 'AbortError') { this._addMessage('system', 'Request cancelled'); } else { console.error('GM Assistant error:', e); this._addMessage('system', `Error: ${e.message}`); this._setStatus('Error', 'error'); } } finally { this.isStreaming = false; this._abortController = null; this._toggleButtons(false); this._setStatus('Ready', 'connected'); } } } // Export for global access globalThis.GMAssistantApp = GMAssistantApp;