Files
gm-assistant/scripts/main.js
kavren 3edf8ad4ce Move tool notifications to status bar, add UI improvements
- Tool calls now display in status bar instead of chat messages
- Added "working" status style (blue, italic)
- Added floating draggable button as UI fallback
- Added Stop, Copy, Save, Clear buttons
- Added markdown rendering for assistant messages
- Improved session handling and abort support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 19:15:10 -05:00

556 lines
16 KiB
JavaScript

// 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 = '<i class="fas fa-robot"></i>';
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 = `
<div class="gm-assistant-messages" data-messages></div>
<div class="gm-assistant-input-area">
<textarea placeholder="Ask Claude anything..." data-input></textarea>
<div class="button-group">
<button type="button" class="send" data-send>Send</button>
<button type="button" class="stop" data-stop style="display:none;background:#d32f2f;">Stop</button>
<button type="button" class="new-chat" data-new-chat>New</button>
<button type="button" class="copy" data-copy>Copy</button>
<button type="button" class="save" data-save>Save</button>
<button type="button" class="clear" data-clear>Clear</button>
</div>
</div>
<div class="gm-assistant-status" data-status>Ready</div>
`;
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 `<div class="${classes.join(' ')}">${content}</div>`;
}).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, '<h4>$1</h4>')
.replace(/^## (.+)$/gm, '<h3>$1</h3>')
.replace(/^# (.+)$/gm, '<h2>$1</h2>')
// Bold and italic
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+?)\*/g, '<em>$1</em>')
// Code blocks (fenced)
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>')
// Inline code (but not if already in HTML tags)
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Blockquotes
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
// Horizontal rules
.replace(/^---$/gm, '<hr>')
// Unordered lists
.replace(/^\* (.+)$/gm, '<li>$1</li>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
// Wrap consecutive li elements in ul
.replace(/(<li>[\s\S]*?<\/li>)(\s*<li>)/g, '$1$2')
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, '<ul>$1</ul>')
// Double newlines to paragraphs, single to br
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>');
}
_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 = '<div class="gm-assistant-log">';
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 += `<div class="log-message ${cssClass}">`;
htmlContent += `<strong>[${label}]</strong><br>`;
htmlContent += content;
htmlContent += '</div><hr>';
}
htmlContent += '</div>';
// 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;