// OpenFang Chat Page — Agent chat with markdown + streaming 'use strict'; function chatPage() { var msgId = 0; return { currentAgent: null, messages: [], inputText: '', sending: false, messageQueue: [], // Queue for messages sent while streaming thinkingMode: 'off', // 'off' | 'on' | 'stream' _wsAgent: null, showSlashMenu: false, slashFilter: '', slashIdx: 0, attachments: [], dragOver: false, contextPressure: 'low', // green/yellow/orange/red indicator _typingTimeout: null, // Multi-session state sessions: [], sessionsOpen: false, searchOpen: false, searchQuery: '', // Voice recording state recording: false, _mediaRecorder: null, _audioChunks: [], recordingTime: 0, _recordingTimer: null, // Model autocomplete state showModelPicker: false, modelPickerList: [], modelPickerFilter: '', modelPickerIdx: 0, // Model switcher dropdown showModelSwitcher: false, modelSwitcherFilter: '', modelSwitcherProviderFilter: '', modelSwitcherIdx: 0, modelSwitching: false, _modelCache: null, _modelCacheTime: 0, slashCommands: [], // Loaded dynamically with i18n in init() _slashCommandsLoaded: false, tokenCount: 0, // ── Tip Bar ── tipIndex: 0, tips: [], _tipsInitialized: false, tipTimer: null, get currentTip() { if (localStorage.getItem('of-tips-off') === 'true') return ''; if (!this._tipsInitialized) { var t = typeof window.t === 'function' ? window.t : function(s) { return s; }; this.tips = [ t('tips.commands'), t('tips.think'), t('tips.focus'), 'Drag files to attach', '/model to switch models', '/context to check usage', '/verbose off to hide tool details' ]; this._tipsInitialized = true; } return this.tips[this.tipIndex % this.tips.length] || ''; }, dismissTips: function() { localStorage.setItem('of-tips-off', 'true'); }, startTipCycle: function() { var self = this; if (this.tipTimer) clearInterval(this.tipTimer); this.tipTimer = setInterval(function() { self.tipIndex = (self.tipIndex + 1) % self.tips.length; }, 30000); }, // Backward compat helper get thinkingEnabled() { return this.thinkingMode !== 'off'; }, // Context pressure dot color get contextDotColor() { switch (this.contextPressure) { case 'critical': return '#ef4444'; case 'high': return '#f97316'; case 'medium': return '#eab308'; default: return '#22c55e'; } }, get modelDisplayName() { if (!this.currentAgent) return ''; var name = this.currentAgent.model_name || ''; var short = name.replace(/-\d{8}$/, ''); return short.length > 24 ? short.substring(0, 22) + '\u2026' : short; }, get switcherProviders() { var seen = {}; (this._modelCache || []).forEach(function(m) { seen[m.provider] = true; }); return Object.keys(seen).sort(); }, get filteredSwitcherModels() { var models = this._modelCache || []; var provFilter = this.modelSwitcherProviderFilter; var textFilter = this.modelSwitcherFilter ? this.modelSwitcherFilter.toLowerCase() : ''; if (!provFilter && !textFilter) return models; return models.filter(function(m) { if (provFilter && m.provider !== provFilter) return false; if (textFilter) { return m.id.toLowerCase().indexOf(textFilter) !== -1 || (m.display_name || '').toLowerCase().indexOf(textFilter) !== -1 || m.provider.toLowerCase().indexOf(textFilter) !== -1; } return true; }); }, get groupedSwitcherModels() { var filtered = this.filteredSwitcherModels; var groups = {}, order = []; filtered.forEach(function(m) { if (!groups[m.provider]) { groups[m.provider] = []; order.push(m.provider); } groups[m.provider].push(m); }); return order.map(function(p) { return { provider: p.charAt(0).toUpperCase() + p.slice(1), models: groups[p] }; }); }, init() { var self = this; // Initialize slash commands with i18n this.initSlashCommands(); // Start tip cycle this.startTipCycle(); // Fetch dynamic commands from server this.fetchCommands(); // Observe DOM for new messages and render LaTeX this._latexObserver = new MutationObserver(function(mutations) { mutations.forEach(function(mutation) { mutation.addedNodes.forEach(function(node) { if (node.nodeType === Node.ELEMENT_NODE) { var bubbles = node.querySelector ? node.querySelectorAll('.message-bubble') : []; if (node.classList && node.classList.contains('message-bubble')) { bubbles = [node]; } bubbles.forEach(function(bubble) { if (bubble.textContent && hasLatexDelimiters(bubble.textContent)) { renderLatex(bubble); } }); } }); }); }); this._latexObserver.observe(document.getElementById('messages') || document.body, { childList: true, subtree: true }); // Ctrl+/ keyboard shortcut document.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && e.key === '/') { e.preventDefault(); var input = document.getElementById('msg-input'); if (input) { input.focus(); self.inputText = '/'; } } // Ctrl+M for model switcher if ((e.ctrlKey || e.metaKey) && e.key === 'm' && self.currentAgent) { e.preventDefault(); self.toggleModelSwitcher(); } // Ctrl+F for chat search if ((e.ctrlKey || e.metaKey) && e.key === 'f' && self.currentAgent) { e.preventDefault(); self.toggleSearch(); } }); // Load session + session list when agent changes this.$watch('currentAgent', function(agent) { if (agent) { self.loadSession(agent.id); self.loadSessions(agent.id); } }); // Check for pending agent from Agents page (set before chat mounted) var store = Alpine.store('app'); if (store.pendingAgent) { self.selectAgent(store.pendingAgent); store.pendingAgent = null; } else { // Restore previously active agent after page refresh (#1179). // The agent list may not be loaded yet, so resolve once it appears. self._restoreActiveAgent(); } // Watch for future pending agent selections (e.g., user clicks agent while on chat) this.$watch('$store.app.pendingAgent', function(agent) { if (agent) { self.selectAgent(agent); Alpine.store('app').pendingAgent = null; } }); // Re-attempt restore once the agent list arrives from the server this.$watch('$store.app.agents', function(agents) { if (!self.currentAgent && agents && agents.length) { self._restoreActiveAgent(); } }); // Watch for slash commands + model autocomplete this.$watch('inputText', function(val) { var modelMatch = val.match(/^\/model\s+(.*)$/i); if (modelMatch) { self.showSlashMenu = false; self.modelPickerFilter = modelMatch[1].toLowerCase(); if (!self.modelPickerList.length) { OpenFangAPI.get('/api/models').then(function(data) { self.modelPickerList = (data.models || []).filter(function(m) { return m.available; }); self.showModelPicker = true; self.modelPickerIdx = 0; }).catch(function() {}); } else { self.showModelPicker = true; } } else if (val.startsWith('/')) { self.showModelPicker = false; self.slashFilter = val.slice(1).toLowerCase(); self.showSlashMenu = true; self.slashIdx = 0; } else { self.showSlashMenu = false; self.showModelPicker = false; } }); }, get filteredModelPicker() { if (!this.modelPickerFilter) return this.modelPickerList.slice(0, 15); var f = this.modelPickerFilter; return this.modelPickerList.filter(function(m) { return m.id.toLowerCase().indexOf(f) !== -1 || (m.display_name || '').toLowerCase().indexOf(f) !== -1 || m.provider.toLowerCase().indexOf(f) !== -1; }).slice(0, 15); }, pickModel(modelId) { this.showModelPicker = false; this.inputText = '/model ' + modelId; this.sendMessage(); }, toggleModelSwitcher() { if (this.showModelSwitcher) { this.showModelSwitcher = false; return; } var self = this; var now = Date.now(); if (this._modelCache && (now - this._modelCacheTime) < 300000) { this.modelSwitcherFilter = ''; this.modelSwitcherProviderFilter = ''; this.modelSwitcherIdx = 0; this.showModelSwitcher = true; this.$nextTick(function() { var el = document.getElementById('model-switcher-search'); if (el) el.focus(); }); return; } OpenFangAPI.get('/api/models').then(function(data) { var models = (data.models || []).filter(function(m) { return m.available; }); self._modelCache = models; self._modelCacheTime = Date.now(); self.modelPickerList = models; self.modelSwitcherFilter = ''; self.modelSwitcherProviderFilter = ''; self.modelSwitcherIdx = 0; self.showModelSwitcher = true; self.$nextTick(function() { var el = document.getElementById('model-switcher-search'); if (el) el.focus(); }); }).catch(function(e) { OpenFangToast.error('Failed to load models: ' + e.message); }); }, switchModel(model) { if (!this.currentAgent) return; if (model.id === this.currentAgent.model_name) { this.showModelSwitcher = false; return; } var self = this; this.modelSwitching = true; var t = typeof window.t === 'function' ? window.t : function(s) { return s; }; OpenFangAPI.put('/api/agents/' + this.currentAgent.id + '/model', { model: model.id }).then(function(resp) { // Use server-resolved model/provider to stay in sync (fixes #387/#466) self.currentAgent.model_name = (resp && resp.model) || model.id; self.currentAgent.model_provider = (resp && resp.provider) || model.provider; OpenFangToast.success(t('chat.model_switched') + ' ' + (model.display_name || model.id)); self.showModelSwitcher = false; self.modelSwitching = false; }).catch(function(e) { OpenFangToast.error(t('chat.model_switch_failed') + ': ' + e.message); self.modelSwitching = false; }); }, // Initialize slash commands with i18n translations initSlashCommands: function() { if (this._slashCommandsLoaded) return; var t = typeof window.t === 'function' ? window.t : function(s) { return s; }; this.slashCommands = [ { cmd: '/help', desc: t('chat.slash.help') }, { cmd: '/agents', desc: t('chat.slash.agents') }, { cmd: '/new', desc: t('chat.slash.new') }, { cmd: '/compact', desc: t('chat.slash.compact') }, { cmd: '/model', desc: t('chat.slash.model') }, { cmd: '/stop', desc: t('chat.slash.stop') }, { cmd: '/usage', desc: t('chat.slash.usage') }, { cmd: '/think', desc: t('chat.slash.think') }, { cmd: '/context', desc: t('chat.slash.context') }, { cmd: '/verbose', desc: t('chat.slash.verbose') }, { cmd: '/queue', desc: t('chat.slash.queue') }, { cmd: '/status', desc: t('chat.slash.status') }, { cmd: '/clear', desc: t('chat.slash.clear') }, { cmd: '/exit', desc: t('chat.slash.exit') }, { cmd: '/budget', desc: t('chat.slash.budget') }, { cmd: '/peers', desc: t('chat.slash.peers') }, { cmd: '/a2a', desc: t('chat.slash.a2a') } ]; this._slashCommandsLoaded = true; }, // Fetch slash commands from the unified registry (/api/commands?surface=web). // Replaces the hardcoded initSlashCommands() list once loaded — ensures // the help panel and autocomplete stay in sync with the backend registry. fetchCommands: function() { var self = this; OpenFangAPI.get('/api/commands?surface=web').then(function(data) { var cmds = (data && data.commands) || []; if (!cmds.length) return; self.slashCommands = cmds.map(function(c) { // Prefer unified-registry shape { name, aliases, description, category, requires_agent }. // Fall back to legacy { cmd, desc } shape so older shims keep working. if (c.name) { return { cmd: '/' + c.name, desc: c.description || '', category: c.category || 'general', aliases: c.aliases || [], requires_agent: !!c.requires_agent, source: 'registry' }; } return { cmd: c.cmd, desc: c.desc || '', category: c.category || 'general', aliases: c.aliases || [], requires_agent: !!c.requires_agent, source: c.source || 'server' }; }); self._slashCommandsLoaded = true; }).catch(function() { /* silent — keep hardcoded fallback list */ }); }, get filteredSlashCommands() { if (!this.slashFilter) return this.slashCommands; var f = this.slashFilter; return this.slashCommands.filter(function(c) { return c.cmd.toLowerCase().indexOf(f) !== -1 || c.desc.toLowerCase().indexOf(f) !== -1; }); }, // Render `/help` output grouped by category, mirroring the // backend's render_help(Surfaces::WEB). Falls back to a flat list if // categories are not populated (pre-fetch hardcoded list). renderHelpText: function() { var order = ['general', 'session', 'model', 'control', 'memory', 'info', 'automation', 'monitoring']; var labels = { general: 'General', session: 'Session', model: 'Model', control: 'Control', memory: 'Memory', info: 'Info', automation: 'Automation', monitoring: 'Monitoring' }; var anyCategorised = this.slashCommands.some(function(c) { return c.category; }); if (!anyCategorised) { return this.slashCommands.map(function(c) { return '`' + c.cmd + '` \u2014 ' + c.desc; }).join('\n'); } var groups = {}; this.slashCommands.forEach(function(c) { var cat = c.category || 'general'; if (!groups[cat]) groups[cat] = []; groups[cat].push(c); }); var lines = ['**Available commands:**']; order.forEach(function(cat) { var list = groups[cat]; if (!list || !list.length) return; lines.push(''); lines.push('**' + (labels[cat] || cat) + '**'); list.forEach(function(c) { var aliasText = ''; if (c.aliases && c.aliases.length) { aliasText = ' (aliases: ' + c.aliases.map(function(a) { return '/' + a; }).join(', ') + ')'; } lines.push('- `' + c.cmd + '`' + aliasText + ' \u2014 ' + c.desc); }); }); return lines.join('\n'); }, // Clear any stuck typing indicator after 120s _resetTypingTimeout: function() { var self = this; if (self._typingTimeout) clearTimeout(self._typingTimeout); self._typingTimeout = setTimeout(function() { // Auto-clear stuck typing indicators self.messages = self.messages.filter(function(m) { return !m.thinking; }); self.sending = false; }, 120000); }, _clearTypingTimeout: function() { if (this._typingTimeout) { clearTimeout(this._typingTimeout); this._typingTimeout = null; } }, executeSlashCommand(cmd, cmdArgs) { this.showSlashMenu = false; this.inputText = ''; var self = this; cmdArgs = cmdArgs || ''; switch (cmd) { case '/help': self.messages.push({ id: ++msgId, role: 'system', text: self.renderHelpText(), meta: '', tools: [] }); self.scrollToBottom(); break; case '/agents': location.hash = 'agents'; break; case '/new': if (self.currentAgent) { OpenFangAPI.post('/api/agents/' + self.currentAgent.id + '/session/reset', {}).then(function() { self.messages = []; OpenFangToast.success('Session reset'); }).catch(function(e) { OpenFangToast.error('Reset failed: ' + e.message); }); } break; case '/compact': if (self.currentAgent) { self.messages.push({ id: ++msgId, role: 'system', text: 'Compacting session...', meta: '', tools: [] }); OpenFangAPI.post('/api/agents/' + self.currentAgent.id + '/session/compact', {}).then(function(res) { self.messages.push({ id: ++msgId, role: 'system', text: res.message || 'Compaction complete', meta: '', tools: [] }); self.scrollToBottom(); }).catch(function(e) { OpenFangToast.error('Compaction failed: ' + e.message); }); } break; case '/stop': if (self.currentAgent) { OpenFangAPI.post('/api/agents/' + self.currentAgent.id + '/stop', {}).then(function(res) { self.messages.push({ id: ++msgId, role: 'system', text: res.message || 'Run cancelled', meta: '', tools: [] }); self.sending = false; self.scrollToBottom(); }).catch(function(e) { OpenFangToast.error('Stop failed: ' + e.message); }); } break; case '/usage': if (self.currentAgent) { var approxTokens = self.messages.reduce(function(sum, m) { return sum + Math.round((m.text || '').length / 4); }, 0); self.messages.push({ id: ++msgId, role: 'system', text: '**Session Usage**\n- Messages: ' + self.messages.length + '\n- Approx tokens: ~' + approxTokens, meta: '', tools: [] }); self.scrollToBottom(); } break; case '/think': if (cmdArgs === 'on') { self.thinkingMode = 'on'; } else if (cmdArgs === 'off') { self.thinkingMode = 'off'; } else if (cmdArgs === 'stream') { self.thinkingMode = 'stream'; } else { // Cycle: off -> on -> stream -> off if (self.thinkingMode === 'off') self.thinkingMode = 'on'; else if (self.thinkingMode === 'on') self.thinkingMode = 'stream'; else self.thinkingMode = 'off'; } var modeLabel = self.thinkingMode === 'stream' ? 'enabled (streaming reasoning)' : (self.thinkingMode === 'on' ? 'enabled' : 'disabled'); self.messages.push({ id: ++msgId, role: 'system', text: 'Extended thinking **' + modeLabel + '**. ' + (self.thinkingMode === 'stream' ? 'Reasoning tokens will appear in a collapsible panel.' : self.thinkingMode === 'on' ? 'The agent will show its reasoning when supported by the model.' : 'Normal response mode.'), meta: '', tools: [] }); self.scrollToBottom(); break; case '/context': // Send via WS command if (self.currentAgent && OpenFangAPI.isWsConnected()) { OpenFangAPI.wsSend({ type: 'command', command: 'context', args: '' }); } else { self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] }); self.scrollToBottom(); } break; case '/verbose': if (self.currentAgent && OpenFangAPI.isWsConnected()) { OpenFangAPI.wsSend({ type: 'command', command: 'verbose', args: cmdArgs }); } else { self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + '). Pick an agent or check that your session is still valid.', meta: '', tools: [] }); self.scrollToBottom(); } break; case '/queue': if (self.currentAgent && OpenFangAPI.isWsConnected()) { OpenFangAPI.wsSend({ type: 'command', command: 'queue', args: '' }); } else { self.messages.push({ id: ++msgId, role: 'system', text: 'Not connected (' + (OpenFangAPI.getConnectionState ? OpenFangAPI.getConnectionState() : 'unknown') + ').', meta: '', tools: [] }); self.scrollToBottom(); } break; case '/status': OpenFangAPI.get('/api/status').then(function(s) { self.messages.push({ id: ++msgId, role: 'system', text: '**System Status**\n- Agents: ' + (s.agent_count || 0) + '\n- Uptime: ' + (s.uptime_seconds || 0) + 's\n- Version: ' + (s.version || '?'), meta: '', tools: [] }); self.scrollToBottom(); }).catch(function() {}); break; case '/model': if (self.currentAgent) { if (cmdArgs) { OpenFangAPI.put('/api/agents/' + self.currentAgent.id + '/model', { model: cmdArgs }).then(function(resp) { // Use server-resolved model/provider (fixes #387/#466) var resolvedModel = (resp && resp.model) || cmdArgs; var resolvedProvider = (resp && resp.provider) || ''; self.currentAgent.model_name = resolvedModel; if (resolvedProvider) { self.currentAgent.model_provider = resolvedProvider; } self.messages.push({ id: ++msgId, role: 'system', text: 'Model switched to: `' + resolvedModel + '`' + (resolvedProvider ? ' (provider: `' + resolvedProvider + '`)' : ''), meta: '', tools: [] }); self.scrollToBottom(); }).catch(function(e) { OpenFangToast.error('Model switch failed: ' + e.message); }); } else { self.messages.push({ id: ++msgId, role: 'system', text: '**Current Model**\n- Provider: `' + (self.currentAgent.model_provider || '?') + '`\n- Model: `' + (self.currentAgent.model_name || '?') + '`', meta: '', tools: [] }); self.scrollToBottom(); } } else { self.messages.push({ id: ++msgId, role: 'system', text: 'No agent selected.', meta: '', tools: [] }); self.scrollToBottom(); } break; case '/clear': self.messages = []; break; case '/exit': OpenFangAPI.wsDisconnect(); self._wsAgent = null; self.currentAgent = null; self.messages = []; try { localStorage.removeItem('of-active-agent'); } catch(e) { /* ignore */ } window.dispatchEvent(new Event('close-chat')); break; case '/budget': OpenFangAPI.get('/api/budget').then(function(b) { var fmt = function(v) { return v > 0 ? '$' + v.toFixed(2) : 'unlimited'; }; self.messages.push({ id: ++msgId, role: 'system', text: '**Budget Status**\n' + '- Hourly: $' + (b.hourly_spend||0).toFixed(4) + ' / ' + fmt(b.hourly_limit) + '\n' + '- Daily: $' + (b.daily_spend||0).toFixed(4) + ' / ' + fmt(b.daily_limit) + '\n' + '- Monthly: $' + (b.monthly_spend||0).toFixed(4) + ' / ' + fmt(b.monthly_limit), meta: '', tools: [] }); self.scrollToBottom(); }).catch(function() {}); break; case '/peers': OpenFangAPI.get('/api/network/status').then(function(ns) { self.messages.push({ id: ++msgId, role: 'system', text: '**OFP Network**\n' + '- Status: ' + (ns.enabled ? 'Enabled' : 'Disabled') + '\n' + '- Connected peers: ' + (ns.connected_peers||0) + ' / ' + (ns.total_peers||0), meta: '', tools: [] }); self.scrollToBottom(); }).catch(function() {}); break; case '/a2a': OpenFangAPI.get('/api/a2a/agents').then(function(res) { var agents = res.agents || []; if (!agents.length) { self.messages.push({ id: ++msgId, role: 'system', text: 'No external A2A agents discovered.', meta: '', tools: [] }); } else { var lines = agents.map(function(a) { return '- **' + a.name + '** — ' + a.url; }); self.messages.push({ id: ++msgId, role: 'system', text: '**A2A Agents (' + agents.length + ')**\n' + lines.join('\n'), meta: '', tools: [] }); } self.scrollToBottom(); }).catch(function() {}); break; } }, // Restore the previously-active agent (set in selectAgent) after a page // refresh, so the WebSocket re-attaches to the same session and any // in-flight tool output streams back into the chat (#1179). _restoreActiveAgent: function() { var storedId = null; try { storedId = localStorage.getItem('of-active-agent'); } catch(e) { /* ignore */ } if (!storedId) return; var agents = (Alpine.store('app') && Alpine.store('app').agents) || []; var match = null; for (var i = 0; i < agents.length; i++) { if (agents[i] && agents[i].id === storedId) { match = agents[i]; break; } } if (match) { this.selectAgent(match); } }, selectAgent(agent) { this.currentAgent = agent; this.messages = []; try { localStorage.setItem('of-active-agent', agent.id); } catch(e) { /* ignore */ } this.connectWs(agent.id); var t = typeof window.t === 'function' ? window.t : function(s) { return s; }; // Show welcome tips on first use if (!localStorage.getItem('of-chat-tips-seen')) { this.messages.push({ id: ++msgId, role: 'system', text: t('chat.welcome_message'), meta: '', tools: [] }); localStorage.setItem('of-chat-tips-seen', 'true'); } // Focus input after agent selection var self = this; this.$nextTick(function() { var el = document.getElementById('msg-input'); if (el) el.focus(); }); }, async loadSession(agentId) { var self = this; try { var data = await OpenFangAPI.get('/api/agents/' + agentId + '/session'); if (data.messages && data.messages.length) { // Defense-in-depth (#935): never render system-role messages in the // conversation history view, even if the backend somehow returns // one. The server already filters these out by default, but we // guard here too so a regression cannot leak the system prompt. var visible = data.messages.filter(function(m) { return m && m.role !== 'System' && m.role !== 'system'; }); self.messages = visible.map(function(m) { var role = m.role === 'User' ? 'user' : (m.role === 'System' ? 'system' : 'agent'); var text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content); // Sanitize any raw function-call text from history text = self.sanitizeToolText(text); // Build tool cards from historical tool data var tools = (m.tools || []).map(function(t, idx) { return { id: (t.name || 'tool') + '-hist-' + idx, name: t.name || 'unknown', running: false, expanded: true, input: t.input || '', result: t.result || '', is_error: !!t.is_error }; }); var images = (m.images || []).map(function(img) { return { file_id: img.file_id, filename: img.filename || 'image' }; }); return { id: ++msgId, role: role, text: text, meta: '', tools: tools, images: images }; }); self.$nextTick(function() { self.scrollToBottom(); }); } } catch(e) { /* silent */ } }, // Multi-session: load session list for current agent async loadSessions(agentId) { try { var data = await OpenFangAPI.get('/api/agents/' + agentId + '/sessions'); this.sessions = data.sessions || []; } catch(e) { this.sessions = []; } }, // Multi-session: create a new session async createSession() { if (!this.currentAgent) return; var t = typeof window.t === 'function' ? window.t : function(s) { return s; }; var label = prompt(t('chat.session_name_prompt')); if (label === null) return; // cancelled try { await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions', { label: label.trim() || undefined }); await this.loadSessions(this.currentAgent.id); await this.loadSession(this.currentAgent.id); this.messages = []; this.scrollToBottom(); if (typeof OpenFangToast !== 'undefined') OpenFangToast.success(t('chat.session_created')); } catch(e) { if (typeof OpenFangToast !== 'undefined') OpenFangToast.error(t('chat.session_create_failed')); } }, // Multi-session: switch to an existing session async switchSession(sessionId) { if (!this.currentAgent) return; try { await OpenFangAPI.post('/api/agents/' + this.currentAgent.id + '/sessions/' + sessionId + '/switch', {}); this.messages = []; await this.loadSession(this.currentAgent.id); await this.loadSessions(this.currentAgent.id); // Reconnect WebSocket for new session this._wsAgent = null; this.connectWs(this.currentAgent.id); } catch(e) { if (typeof OpenFangToast !== 'undefined') OpenFangToast.error('Failed to switch session'); } }, connectWs(agentId) { if (this._wsAgent === agentId) return; this._wsAgent = agentId; var self = this; OpenFangAPI.wsConnect(agentId, { onOpen: function() { Alpine.store('app').wsConnected = true; }, onMessage: function(data) { self.handleWsMessage(data); }, onClose: function() { Alpine.store('app').wsConnected = false; self._wsAgent = null; }, onError: function() { Alpine.store('app').wsConnected = false; self._wsAgent = null; } }); }, handleWsMessage(data) { switch (data.type) { case 'connected': break; // Incoming message from server (e.g., cron trigger) — display as user message case 'message': if (data.content) { var meta = data.source === 'cron' ? '[Scheduled: ' + (data.job_name || data.job_id || '') + ']' : ''; this.messages.push({ id: ++msgId, role: 'user', text: data.content, meta: meta, tools: [], images: [], ts: Date.now() }); this.scrollToBottom(); } break; // Legacy thinking event (backward compat) case 'thinking': if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) { var thinkLabel = data.level ? 'Thinking (' + data.level + ')...' : 'Processing...'; this.messages.push({ id: ++msgId, role: 'agent', text: thinkLabel, meta: '', thinking: true, streaming: true, tools: [] }); this.scrollToBottom(); this._resetTypingTimeout(); } else if (data.level) { var thinkIdx = this.messages.length - 1; var lastThink = thinkIdx >= 0 ? this.messages[thinkIdx] : null; if (lastThink && lastThink.thinking) { lastThink.text = 'Thinking (' + data.level + ')...'; this.messages.splice(thinkIdx, 1, lastThink); } } break; // New typing lifecycle case 'typing': if (data.state === 'start') { if (!this.messages.length || !this.messages[this.messages.length - 1].thinking) { this.messages.push({ id: ++msgId, role: 'agent', text: 'Processing...', meta: '', thinking: true, streaming: true, tools: [] }); this.scrollToBottom(); } this._resetTypingTimeout(); } else if (data.state === 'tool') { var toolTypIdx = this.messages.length - 1; var typingMsg = toolTypIdx >= 0 ? this.messages[toolTypIdx] : null; if (typingMsg && (typingMsg.thinking || typingMsg.streaming)) { typingMsg.text = 'Using ' + (data.tool || 'tool') + '...'; this.messages.splice(toolTypIdx, 1, typingMsg); } this._resetTypingTimeout(); } else if (data.state === 'stop') { this._clearTypingTimeout(); } break; case 'phase': // Show tool/phase progress so the user sees the agent is working var phaseIdx = this.messages.length - 1; var phaseMsg = phaseIdx >= 0 ? this.messages[phaseIdx] : null; if (phaseMsg && (phaseMsg.thinking || phaseMsg.streaming)) { // Skip phases that have no user-meaningful display text — "streaming" // and "done" are lifecycle signals, not status to show in the chat bubble. if (data.phase === 'streaming' || data.phase === 'done') { break; } // Context warning: show prominently as a separate system message if (data.phase === 'context_warning') { var cwDetail = data.detail || 'Context limit reached.'; this.messages.push({ id: ++msgId, role: 'system', text: cwDetail, meta: '', tools: [] }); } else if (data.phase === 'thinking' && this.thinkingMode === 'stream') { // Stream reasoning tokens to a collapsible panel if (!phaseMsg._reasoning) phaseMsg._reasoning = ''; phaseMsg._reasoning += (data.detail || '') + '\n'; phaseMsg.text = '
Reasoning...\n\n' + phaseMsg._reasoning + '
'; this.messages.splice(phaseIdx, 1, phaseMsg); } else if (phaseMsg.thinking) { // Only update text on messages still in thinking state (not yet // receiving streamed content) to avoid overwriting accumulated text. var phaseDetail; if (data.phase === 'tool_use') { phaseDetail = 'Using ' + (data.detail || 'tool') + '...'; } else if (data.phase === 'thinking') { phaseDetail = 'Thinking...'; } else { phaseDetail = data.detail || 'Working...'; } phaseMsg.text = phaseDetail; this.messages.splice(phaseIdx, 1, phaseMsg); } } this.scrollToBottom(); break; case 'text_delta': var lastIdx = this.messages.length - 1; var last = lastIdx >= 0 ? this.messages[lastIdx] : null; if (last && last.streaming) { if (last.thinking) { last.text = ''; last.thinking = false; } // If we already detected a text-based tool call, skip further text if (last._toolTextDetected) break; last.text += data.content; // Detect function-call patterns streamed as text and convert to tool cards var fcIdx = last.text.search(/\w+<\/function[=,>]/); if (fcIdx === -1) fcIdx = last.text.search(//); if (fcIdx !== -1) { var fcPart = last.text.substring(fcIdx); var toolMatch = fcPart.match(/^(\w+)<\/function/) || fcPart.match(/^/); last.text = last.text.substring(0, fcIdx).trim(); last._toolTextDetected = true; if (toolMatch) { if (!last.tools) last.tools = []; var inputMatch = fcPart.match(/[=,>]\s*(\{[\s\S]*)/); last.tools.push({ id: toolMatch[1] + '-txt-' + Date.now(), name: toolMatch[1], running: true, expanded: true, input: inputMatch ? inputMatch[1].replace(/<\/function>?\s*$/, '').trim() : '', result: '', is_error: false }); } } this.tokenCount = Math.round(last.text.length / 4); // Force Alpine reactivity: splice-in-place so x-for re-renders // this item. Direct property mutation on array elements may not // trigger DOM updates from async WebSocket callbacks. this.messages.splice(lastIdx, 1, last); } else { this.messages.push({ id: ++msgId, role: 'agent', text: data.content, meta: '', streaming: true, tools: [] }); } this.scrollToBottom(); break; case 'tool_start': var tsIdx = this.messages.length - 1; var lastMsg = tsIdx >= 0 ? this.messages[tsIdx] : null; if (lastMsg && lastMsg.streaming) { if (!lastMsg.tools) lastMsg.tools = []; lastMsg.tools.push({ id: data.tool + '-' + Date.now(), name: data.tool, running: true, expanded: true, input: '', result: '', is_error: false }); this.messages.splice(tsIdx, 1, lastMsg); } this.scrollToBottom(); break; case 'tool_end': // Tool call parsed by LLM — update tool card with input params var teIdx = this.messages.length - 1; var lastMsg2 = teIdx >= 0 ? this.messages[teIdx] : null; if (lastMsg2 && lastMsg2.tools) { for (var ti = lastMsg2.tools.length - 1; ti >= 0; ti--) { if (lastMsg2.tools[ti].name === data.tool && lastMsg2.tools[ti].running) { lastMsg2.tools[ti].input = data.input || ''; break; } } this.messages.splice(teIdx, 1, lastMsg2); } break; case 'tool_result': // Tool execution completed — update tool card with result var trIdx = this.messages.length - 1; var lastMsg3 = trIdx >= 0 ? this.messages[trIdx] : null; if (lastMsg3 && lastMsg3.tools) { for (var ri = lastMsg3.tools.length - 1; ri >= 0; ri--) { if (lastMsg3.tools[ri].name === data.tool && lastMsg3.tools[ri].running) { lastMsg3.tools[ri].running = false; lastMsg3.tools[ri].result = data.result || ''; lastMsg3.tools[ri].is_error = !!data.is_error; // Extract image URLs from image_generate or browser_screenshot results if ((data.tool === 'image_generate' || data.tool === 'browser_screenshot') && !data.is_error) { try { var parsed = JSON.parse(data.result); if (parsed.image_urls && parsed.image_urls.length) { lastMsg3.tools[ri]._imageUrls = parsed.image_urls; } } catch(e) { /* not JSON */ } } // Extract audio file path from text_to_speech results if (data.tool === 'text_to_speech' && !data.is_error) { try { var ttsResult = JSON.parse(data.result); if (ttsResult.saved_to) { lastMsg3.tools[ri]._audioFile = ttsResult.saved_to; lastMsg3.tools[ri]._audioDuration = ttsResult.duration_estimate_ms; } } catch(e) { /* not JSON */ } } break; } } this.messages.splice(trIdx, 1, lastMsg3); } this.scrollToBottom(); break; case 'response': this._clearTypingTimeout(); // Update context pressure from response if (data.context_pressure) { this.contextPressure = data.context_pressure; } // Collect streamed text before removing streaming messages var streamedText = ''; var streamedTools = []; this.messages.forEach(function(m) { if (m.streaming && !m.thinking && m.role === 'agent') { streamedText += m.text || ''; streamedTools = streamedTools.concat(m.tools || []); } }); streamedTools.forEach(function(t) { t.running = false; // Text-detected tool calls (model leaked as text) — mark as not executed if (t.id && t.id.indexOf('-txt-') !== -1 && !t.result) { t.result = 'Model attempted this call as text (not executed via tool system)'; t.is_error = true; } }); this.messages = this.messages.filter(function(m) { return !m.thinking && !m.streaming; }); var meta = (data.input_tokens || 0) + ' in / ' + (data.output_tokens || 0) + ' out'; if (data.cost_usd != null) meta += ' | $' + data.cost_usd.toFixed(4); if (data.iterations) meta += ' | ' + data.iterations + ' iter'; if (data.fallback_model) meta += ' | fallback: ' + data.fallback_model; // Use server response if non-empty, otherwise preserve accumulated streamed text var finalText = (data.content && data.content.trim()) ? data.content : streamedText; // Strip raw function-call JSON that some models leak as text finalText = this.sanitizeToolText(finalText); // If text is empty but tools ran, show a summary if (!finalText.trim() && streamedTools.length) { finalText = ''; } this.messages.push({ id: ++msgId, role: 'agent', text: finalText, meta: meta, tools: streamedTools, ts: Date.now() }); this.sending = false; this.tokenCount = 0; this.scrollToBottom(); var self3 = this; this.$nextTick(function() { var el = document.getElementById('msg-input'); if (el) el.focus(); self3._processQueue(); }); break; case 'silent_complete': // Agent intentionally chose not to reply (NO_REPLY) this._clearTypingTimeout(); this.messages = this.messages.filter(function(m) { return !m.thinking && !m.streaming; }); this.sending = false; this.tokenCount = 0; // No message bubble added — the agent was silent var selfSilent = this; this.$nextTick(function() { selfSilent._processQueue(); }); break; case 'error': this._clearTypingTimeout(); this.messages = this.messages.filter(function(m) { return !m.thinking && !m.streaming; }); this.messages.push({ id: ++msgId, role: 'system', text: 'Error: ' + data.content, meta: '', tools: [], ts: Date.now() }); this.sending = false; this.tokenCount = 0; this.scrollToBottom(); var self2 = this; this.$nextTick(function() { var el = document.getElementById('msg-input'); if (el) el.focus(); self2._processQueue(); }); break; case 'agents_updated': if (data.agents) { Alpine.store('app').agents = data.agents; Alpine.store('app').agentCount = data.agents.length; } break; case 'command_result': // Update context pressure if included in command result if (data.context_pressure) { this.contextPressure = data.context_pressure; } this.messages.push({ id: ++msgId, role: 'system', text: data.message || 'Command executed.', meta: '', tools: [] }); this.scrollToBottom(); break; case 'canvas': // Agent presented an interactive canvas — render it in an iframe sandbox var canvasHtml = '
'; canvasHtml += '
'; canvasHtml += '' + (data.title || 'Canvas') + ''; canvasHtml += '' + (data.canvas_id || '').substring(0, 8) + '
'; canvasHtml += '