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:
21
module.json
Normal file
21
module.json
Normal 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
297
scripts/main.js
Normal 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
160
styles/gm-assistant.css
Normal 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%);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user