diff --git a/module.json b/module.json index b68cc09..be0d9ec 100644 --- a/module.json +++ b/module.json @@ -17,5 +17,8 @@ ], "styles": [ "styles/gm-assistant.css" - ] + ], + "url": "https://git.kavcorp.com/kavren/gm-assistant", + "manifest": "https://git.kavcorp.com/kavren/gm-assistant/raw/branch/main/module.json", + "download": "https://git.kavcorp.com/kavren/gm-assistant/releases/download/v0.1.0/gm-assistant.zip" } diff --git a/scripts/main.js b/scripts/main.js index 27c0d8e..72cdeda 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -23,10 +23,11 @@ Hooks.once('ready', () => { console.log('GM Assistant | Module ready'); }); -// Add button to scene controls +// 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({ @@ -39,6 +40,78 @@ Hooks.on('getSceneControlButtons', (controls) => { } }); +// 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 = { @@ -102,7 +175,11 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
- + + + + +
Ready
@@ -110,6 +187,10 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { return html; } + _replaceHTML(result, content, options) { + content.replaceChildren(result); + } + _onRender(context, options) { const html = this.element; @@ -117,13 +198,19 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { 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(); @@ -131,6 +218,17 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { // 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) { @@ -139,14 +237,35 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { } }); - // New chat button + // 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(); } @@ -171,12 +290,19 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { } } + _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]; - return `
${this._escapeHtml(msg.content)}
`; + // 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 @@ -189,17 +315,139 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { 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) { - const last = this.messages[this.messages.length - 1]; - if (last && last.type === 'assistant') { - last.content = content; - this._renderMessages(); + // 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() { @@ -216,6 +464,10 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { 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`, { @@ -224,7 +476,8 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { body: JSON.stringify({ message: message, sessionId: this.sessionId - }) + }), + signal: this._abortController.signal }); if (!response.ok) { @@ -262,9 +515,8 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { break; case 'tool': - this._addMessage('tool', `Using: ${data.name}`); - // Re-add assistant placeholder after tool message - this._addMessage('assistant', this.currentResponse); + // Show tool use in status line instead of cluttering chat + this._setStatus(`Using: ${data.name}`, 'working'); break; case 'done': @@ -283,11 +535,17 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 { } } catch (e) { - console.error('GM Assistant error:', e); - this._addMessage('system', `Error: ${e.message}`); - this._setStatus('Error', 'error'); + 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'); } } diff --git a/styles/gm-assistant.css b/styles/gm-assistant.css index 3e694d5..a981f2a 100644 --- a/styles/gm-assistant.css +++ b/styles/gm-assistant.css @@ -44,6 +44,72 @@ .gm-assistant-message.assistant { background: var(--color-bg-btn); margin-right: auto; + max-width: 100%; +} + +/* Markdown styles for assistant messages */ +.gm-assistant-message.assistant h2 { + font-size: 1.2em; + margin: 0.5em 0 0.3em; + border-bottom: 1px solid var(--color-border-light); +} + +.gm-assistant-message.assistant h3 { + font-size: 1.1em; + margin: 0.5em 0 0.3em; +} + +.gm-assistant-message.assistant h4 { + font-size: 1em; + margin: 0.4em 0 0.2em; +} + +.gm-assistant-message.assistant strong { + color: var(--color-text-light-highlight); +} + +.gm-assistant-message.assistant code { + background: rgba(0, 0, 0, 0.2); + padding: 1px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 0.9em; +} + +.gm-assistant-message.assistant pre { + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 4px; + overflow-x: auto; + margin: 0.5em 0; +} + +.gm-assistant-message.assistant pre code { + background: none; + padding: 0; +} + +.gm-assistant-message.assistant blockquote { + border-left: 3px solid var(--color-warm-1); + margin: 0.5em 0; + padding-left: 10px; + font-style: italic; + color: var(--color-text-light-secondary); +} + +.gm-assistant-message.assistant ul { + margin: 0.3em 0; + padding-left: 1.5em; +} + +.gm-assistant-message.assistant li { + margin: 0.2em 0; +} + +.gm-assistant-message.assistant hr { + border: none; + border-top: 1px solid var(--color-border-light); + margin: 0.5em 0; } .gm-assistant-message.system { @@ -98,6 +164,10 @@ font-size: 0.85em; } +.gm-assistant-input-area button.save { + background: var(--color-cool-3); +} + /* Status bar */ .gm-assistant-status { padding: 5px 10px; @@ -115,6 +185,11 @@ color: #f44336; } +.gm-assistant-status.working { + color: #64b5f6; + font-style: italic; +} + /* Typing indicator */ .gm-assistant-typing { display: flex;