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:
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;
|
||||
Reference in New Issue
Block a user