';
canvasHtml += '
';
canvasHtml += '' + (data.title || 'Canvas') + '';
canvasHtml += '' + (data.canvas_id || '').substring(0, 8) + '
';
canvasHtml += '
';
this.messages.push({ id: ++msgId, role: 'agent', text: canvasHtml, meta: 'canvas', isHtml: true, tools: [] });
this.scrollToBottom();
break;
case 'pong': break;
}
},
// Format timestamp for display
formatTime: function(ts) {
if (!ts) return '';
var d = new Date(ts);
var h = d.getHours();
var m = d.getMinutes();
var ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12 || 12;
return h + ':' + (m < 10 ? '0' : '') + m + ' ' + ampm;
},
// Copy message text to clipboard
copyMessage: function(msg) {
var text = msg.text || '';
navigator.clipboard.writeText(text).then(function() {
msg._copied = true;
setTimeout(function() { msg._copied = false; }, 2000);
}).catch(function() {});
},
// Process queued messages after current response completes
_processQueue: function() {
if (!this.messageQueue.length || this.sending) return;
var next = this.messageQueue.shift();
this._sendPayload(next.text, next.files, next.images);
},
async sendMessage() {
if (!this.currentAgent || (!this.inputText.trim() && !this.attachments.length)) return;
var text = this.inputText.trim();
// Handle slash commands
if (text.startsWith('/') && !this.attachments.length) {
var cmd = text.split(' ')[0].toLowerCase();
var cmdArgs = text.substring(cmd.length).trim();
var matched = this.slashCommands.find(function(c) { return c.cmd === cmd; });
if (matched) {
this.executeSlashCommand(matched.cmd, cmdArgs);
return;
}
}
this.inputText = '';
// Reset textarea height to single line
var ta = document.getElementById('msg-input');
if (ta) ta.style.height = '';
// Upload attachments first if any
var fileRefs = [];
var uploadedFiles = [];
if (this.attachments.length) {
for (var i = 0; i < this.attachments.length; i++) {
var att = this.attachments[i];
att.uploading = true;
try {
var uploadRes = await OpenFangAPI.upload(this.currentAgent.id, att.file);
fileRefs.push('[File: ' + att.file.name + ']');
uploadedFiles.push({ file_id: uploadRes.file_id, filename: uploadRes.filename, content_type: uploadRes.content_type });
} catch(e) {
OpenFangToast.error('Failed to upload ' + att.file.name);
fileRefs.push('[File: ' + att.file.name + ' (upload failed)]');
}
att.uploading = false;
}
// Clean up previews
for (var j = 0; j < this.attachments.length; j++) {
if (this.attachments[j].preview) URL.revokeObjectURL(this.attachments[j].preview);
}
this.attachments = [];
}
// Build final message text
var finalText = text;
if (fileRefs.length) {
finalText = (text ? text + '\n' : '') + fileRefs.join('\n');
}
// Collect image references for inline rendering
var msgImages = uploadedFiles.filter(function(f) { return f.content_type && f.content_type.startsWith('image/'); });
// Always show user message immediately
this.messages.push({ id: ++msgId, role: 'user', text: finalText, meta: '', tools: [], images: msgImages, ts: Date.now() });
this.scrollToBottom();
localStorage.setItem('of-first-msg', 'true');
// If already streaming, queue this message
if (this.sending) {
this.messageQueue.push({ text: finalText, files: uploadedFiles, images: msgImages });
return;
}
this._sendPayload(finalText, uploadedFiles, msgImages);
},
async _sendPayload(finalText, uploadedFiles, msgImages) {
this.sending = true;
// Try WebSocket first
var wsPayload = { type: 'message', content: finalText };
if (uploadedFiles && uploadedFiles.length) wsPayload.attachments = uploadedFiles;
if (OpenFangAPI.wsSend(wsPayload)) {
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, streaming: true, tools: [], ts: Date.now() });
this.scrollToBottom();
return;
}
// HTTP fallback
if (!OpenFangAPI.isWsConnected()) {
OpenFangToast.info('Using HTTP mode (no streaming)');
}
this.messages.push({ id: ++msgId, role: 'agent', text: '', meta: '', thinking: true, tools: [], ts: Date.now() });
this.scrollToBottom();
try {
var httpBody = { message: finalText };
if (uploadedFiles && uploadedFiles.length) httpBody.attachments = uploadedFiles;
var res = await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/message', httpBody);
this.messages = this.messages.filter(function(m) { return !m.thinking; });
var httpMeta = (res.input_tokens || 0) + ' in / ' + (res.output_tokens || 0) + ' out';
if (res.cost_usd != null) httpMeta += ' | $' + res.cost_usd.toFixed(4);
if (res.iterations) httpMeta += ' | ' + res.iterations + ' iter';
this.messages.push({ id: ++msgId, role: 'agent', text: res.response, meta: httpMeta, tools: [], ts: Date.now() });
} catch(e) {
this.messages = this.messages.filter(function(m) { return !m.thinking; });
this.messages.push({ id: ++msgId, role: 'system', text: 'Error: ' + e.message, meta: '', tools: [], ts: Date.now() });
}
this.sending = false;
this.scrollToBottom();
// Process next queued message
var self = this;
this.$nextTick(function() {
var el = document.getElementById('msg-input'); if (el) el.focus();
self._processQueue();
});
},
// Stop the current agent run
stopAgent: function() {
if (!this.currentAgent) return;
var self = this;
OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/stop', {}).then(function(res) {
self.messages.push({ id: ++msgId, role: 'system', text: res.message || 'Run cancelled', meta: '', tools: [], ts: Date.now() });
self.sending = false;
self.scrollToBottom();
self.$nextTick(function() { self._processQueue(); });
}).catch(function(e) { OpenFangToast.error('Stop failed: ' + e.message); });
},
killAgent() {
if (!this.currentAgent) return;
var self = this;
var name = this.currentAgent.name;
OpenFangToast.confirm('Stop Agent', 'Stop agent "' + name + '"? The agent will be shut down.', async function() {
try {
await OpenFangAPI.del('/api/agents/' + self.currentAgent.id);
OpenFangAPI.wsDisconnect();
self._wsAgent = null;
self.currentAgent = null;
self.messages = [];
OpenFangToast.success('Agent "' + name + '" stopped');
Alpine.store('app').refreshAgents();
} catch(e) {
OpenFangToast.error('Failed to stop agent: ' + e.message);
}
});
},
scrollToBottom() {
var self = this;
var el = document.getElementById('messages');
if (el) self.$nextTick(function() { el.scrollTop = el.scrollHeight; });
},
addFiles(files) {
var self = this;
var allowed = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'text/plain', 'application/pdf',
'text/markdown', 'application/json', 'text/csv'];
var allowedExts = ['.txt', '.pdf', '.md', '.json', '.csv'];
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (file.size > 10 * 1024 * 1024) {
OpenFangToast.warn('File "' + file.name + '" exceeds 10MB limit');
continue;
}
var typeOk = allowed.indexOf(file.type) !== -1;
if (!typeOk) {
var ext = file.name.lastIndexOf('.') !== -1 ? file.name.substring(file.name.lastIndexOf('.')).toLowerCase() : '';
typeOk = allowedExts.indexOf(ext) !== -1 || file.type.startsWith('image/');
}
if (!typeOk) {
OpenFangToast.warn('File type not supported: ' + file.name);
continue;
}
var preview = null;
if (file.type.startsWith('image/')) {
preview = URL.createObjectURL(file);
}
self.attachments.push({ file: file, preview: preview, uploading: false });
}
},
removeAttachment(idx) {
var att = this.attachments[idx];
if (att && att.preview) URL.revokeObjectURL(att.preview);
this.attachments.splice(idx, 1);
},
handleDrop(e) {
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length) {
this.addFiles(e.dataTransfer.files);
}
},
isGrouped(idx) {
if (idx === 0) return false;
var prev = this.messages[idx - 1];
var curr = this.messages[idx];
return prev && curr && prev.role === curr.role && !curr.thinking && !prev.thinking;
},
// Strip raw function-call text that some models (Llama, Groq, etc.) leak into output.
// These models don't use proper tool_use blocks — they output function calls as plain text.
sanitizeToolText: function(text) {
if (!text) return text;
// Pattern: tool_name