commit a1d32379c0d6b9e9dac5dd6dc21936a9dd3974a2 Author: kavren Date: Sun Dec 7 22:28:11 2025 -0500 Initial commit: GM Assistant 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/module.json b/module.json new file mode 100644 index 0000000..b68cc09 --- /dev/null +++ b/module.json @@ -0,0 +1,21 @@ +{ + "id": "gm-assistant", + "title": "GM Assistant", + "description": "Claude-powered GM assistant for live D&D sessions", + "version": "0.1.0", + "compatibility": { + "minimum": "13", + "verified": "13" + }, + "authors": [ + { + "name": "kavren" + } + ], + "esmodules": [ + "scripts/main.js" + ], + "styles": [ + "styles/gm-assistant.css" + ] +} diff --git a/scripts/main.js b/scripts/main.js new file mode 100644 index 0000000..27c0d8e --- /dev/null +++ b/scripts/main.js @@ -0,0 +1,297 @@ +// 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; diff --git a/styles/gm-assistant.css b/styles/gm-assistant.css new file mode 100644 index 0000000..3e694d5 --- /dev/null +++ b/styles/gm-assistant.css @@ -0,0 +1,160 @@ +/* GM Assistant Styles */ + +.gm-assistant-window { + min-width: 400px; + min-height: 300px; +} + +.gm-assistant-window .window-content { + display: flex; + flex-direction: column; + padding: 0; + height: 100%; +} + +.gm-assistant-container { + display: flex; + flex-direction: column; + height: 100%; +} + +/* Messages area */ +.gm-assistant-messages { + flex: 1; + overflow-y: auto; + padding: 10px; + background: rgba(0, 0, 0, 0.1); + border-bottom: 1px solid var(--color-border-light); +} + +.gm-assistant-message { + margin-bottom: 10px; + padding: 8px 12px; + border-radius: 8px; + max-width: 90%; + word-wrap: break-word; +} + +.gm-assistant-message.user { + background: var(--color-bg-option); + margin-left: auto; + text-align: right; +} + +.gm-assistant-message.assistant { + background: var(--color-bg-btn); + margin-right: auto; +} + +.gm-assistant-message.system { + background: rgba(255, 193, 7, 0.2); + text-align: center; + font-style: italic; + font-size: 0.85em; + max-width: 100%; +} + +.gm-assistant-message.tool { + background: rgba(100, 100, 255, 0.15); + font-size: 0.85em; + font-family: monospace; + max-width: 100%; +} + +/* Input area */ +.gm-assistant-input-area { + display: flex; + gap: 5px; + padding: 10px; + background: var(--color-bg); + border-top: 1px solid var(--color-border-light); +} + +.gm-assistant-input-area textarea { + flex: 1; + resize: none; + min-height: 60px; + max-height: 120px; + padding: 8px; + border-radius: 4px; +} + +.gm-assistant-input-area .button-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.gm-assistant-input-area button { + padding: 5px 10px; + min-width: 70px; +} + +.gm-assistant-input-area button.send { + background: var(--color-warm-1); +} + +.gm-assistant-input-area button.new-chat { + font-size: 0.85em; +} + +/* Status bar */ +.gm-assistant-status { + padding: 5px 10px; + font-size: 0.8em; + color: var(--color-text-light-secondary); + background: rgba(0, 0, 0, 0.05); + border-top: 1px solid var(--color-border-light); +} + +.gm-assistant-status.connected { + color: #4caf50; +} + +.gm-assistant-status.error { + color: #f44336; +} + +/* Typing indicator */ +.gm-assistant-typing { + display: flex; + align-items: center; + gap: 5px; + padding: 8px 12px; + color: var(--color-text-light-secondary); +} + +.gm-assistant-typing .dots { + display: flex; + gap: 3px; +} + +.gm-assistant-typing .dot { + width: 6px; + height: 6px; + background: var(--color-text-light-secondary); + border-radius: 50%; + animation: typing 1.4s infinite; +} + +.gm-assistant-typing .dot:nth-child(2) { + animation-delay: 0.2s; +} + +.gm-assistant-typing .dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing { + 0%, 60%, 100% { opacity: 0.3; transform: translateY(0); } + 30% { opacity: 1; transform: translateY(-3px); } +} + +/* Scene controls button */ +.control-tool[data-tool="gm-assistant"] { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.control-tool[data-tool="gm-assistant"]:hover { + background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); +}