🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
298 lines
7.7 KiB
JavaScript
298 lines
7.7 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
|
|
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;
|