Move tool notifications to status bar, add UI improvements
- Tool calls now display in status bar instead of chat messages - Added "working" status style (blue, italic) - Added floating draggable button as UI fallback - Added Stop, Copy, Save, Clear buttons - Added markdown rendering for assistant messages - Improved session handling and abort support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,5 +17,8 @@
|
||||
],
|
||||
"styles": [
|
||||
"styles/gm-assistant.css"
|
||||
]
|
||||
],
|
||||
"url": "https://git.kavcorp.com/kavren/gm-assistant",
|
||||
"manifest": "https://git.kavcorp.com/kavren/gm-assistant/raw/branch/main/module.json",
|
||||
"download": "https://git.kavcorp.com/kavren/gm-assistant/releases/download/v0.1.0/gm-assistant.zip"
|
||||
}
|
||||
|
||||
280
scripts/main.js
280
scripts/main.js
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,72 @@
|
||||
.gm-assistant-message.assistant {
|
||||
background: var(--color-bg-btn);
|
||||
margin-right: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Markdown styles for assistant messages */
|
||||
.gm-assistant-message.assistant h2 {
|
||||
font-size: 1.2em;
|
||||
margin: 0.5em 0 0.3em;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant h3 {
|
||||
font-size: 1.1em;
|
||||
margin: 0.5em 0 0.3em;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant h4 {
|
||||
font-size: 1em;
|
||||
margin: 0.4em 0 0.2em;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant strong {
|
||||
color: var(--color-text-light-highlight);
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant code {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant pre {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant blockquote {
|
||||
border-left: 3px solid var(--color-warm-1);
|
||||
margin: 0.5em 0;
|
||||
padding-left: 10px;
|
||||
font-style: italic;
|
||||
color: var(--color-text-light-secondary);
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant ul {
|
||||
margin: 0.3em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
.gm-assistant-message.assistant hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.gm-assistant-message.system {
|
||||
@@ -98,6 +164,10 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.gm-assistant-input-area button.save {
|
||||
background: var(--color-cool-3);
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.gm-assistant-status {
|
||||
padding: 5px 10px;
|
||||
@@ -115,6 +185,11 @@
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.gm-assistant-status.working {
|
||||
color: #64b5f6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Typing indicator */
|
||||
.gm-assistant-typing {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user