Initial commit: GM Assistant

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 22:28:11 -05:00
commit a1d32379c0
3 changed files with 478 additions and 0 deletions

21
module.json Normal file
View File

@@ -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"
]
}

297
scripts/main.js Normal file
View File

@@ -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 = `
<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="new-chat" data-new-chat>New Chat</button>
</div>
</div>
<div class="gm-assistant-status" data-status>Ready</div>
`;
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 `<div class="${classes.join(' ')}">${this._escapeHtml(msg.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;
}
_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;

160
styles/gm-assistant.css Normal file
View File

@@ -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%);
}