|
|
|
|
@@ -23,10 +23,11 @@ Hooks.once('ready', () => {
|
|
|
|
|
console.log('GM Assistant | Module ready');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add button to scene controls
|
|
|
|
|
// 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({
|
|
|
|
|
@@ -39,6 +40,78 @@ Hooks.on('getSceneControlButtons', (controls) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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 = {
|
|
|
|
|
@@ -102,7 +175,11 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
<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>
|
|
|
|
|
<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>
|
|
|
|
|
@@ -110,6 +187,10 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_replaceHTML(result, content, options) {
|
|
|
|
|
content.replaceChildren(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_onRender(context, options) {
|
|
|
|
|
const html = this.element;
|
|
|
|
|
|
|
|
|
|
@@ -117,13 +198,19 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
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();
|
|
|
|
|
@@ -131,6 +218,17 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
// 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) {
|
|
|
|
|
@@ -139,14 +237,35 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// New chat button
|
|
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
@@ -171,12 +290,19 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_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];
|
|
|
|
|
return `<div class="${classes.join(' ')}">${this._escapeHtml(msg.content)}</div>`;
|
|
|
|
|
// 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
|
|
|
|
|
@@ -189,18 +315,140 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
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) {
|
|
|
|
|
const last = this.messages[this.messages.length - 1];
|
|
|
|
|
if (last && last.type === 'assistant') {
|
|
|
|
|
last.content = 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;
|
|
|
|
|
@@ -216,6 +464,10 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
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`, {
|
|
|
|
|
@@ -224,7 +476,8 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
message: message,
|
|
|
|
|
sessionId: this.sessionId
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
signal: this._abortController.signal
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
@@ -262,9 +515,8 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'tool':
|
|
|
|
|
this._addMessage('tool', `Using: ${data.name}`);
|
|
|
|
|
// Re-add assistant placeholder after tool message
|
|
|
|
|
this._addMessage('assistant', this.currentResponse);
|
|
|
|
|
// Show tool use in status line instead of cluttering chat
|
|
|
|
|
this._setStatus(`Using: ${data.name}`, 'working');
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'done':
|
|
|
|
|
@@ -283,11 +535,17 @@ class GMAssistantApp extends foundry.applications.api.ApplicationV2 {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} 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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|