/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /** * @typedef {object} AgentUpdateData * @property {string} messageId - Id of the chat message carrying the agent card * @property {string} toolCallId - Correlation id stamped on the card's toolUIData * @property {string} updateType - One of AGENT_UPDATE_TYPES * @property {object} updateData - Payload from the card's action event * * @typedef {object} AgentHandlerContext * @property {object} message - The chat message the card is attached to * @property {object} updateData - The card action payload * @property {object} conversation - The active ChatConversation * @property {ChromeWindow} window - The browser window * * @typedef {object} AgentCommandContext * @property {string} [text] - The user's prompt text accompanying the command * @property {string} [contextPageUrl] - Current page URL to seed the agent with * @property {object} conversation - The active ChatConversation * @property {ChromeWindow} [window] - The browser window */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { MonitorAgent: "moz-src:///browser/components/aiwindow/models/agents/MonitorAgent.sys.mjs", IntervalSchedule: "moz-src:///browser/components/aiwindow/models/agents/Schedule.sys.mjs", DailySchedule: "moz-src:///browser/components/aiwindow/models/agents/Schedule.sys.mjs", WeeklySchedule: "moz-src:///browser/components/aiwindow/models/agents/Schedule.sys.mjs", MONITOR_AGENTS_CHANGED_TOPIC: "moz-src:///browser/components/aiwindow/models/agents/Monitor.sys.mjs", TOTAL_NUM_MONITORS: "moz-src:///browser/components/aiwindow/models/agents/Monitor.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "console", () => console.createInstance({ prefix: "AgentUI", maxLogLevelPref: "browser.smartwindow.agentUI.logLevel", }) ); /** * uiType values rendered as agent cards in chat * Kept in sync with the UI_TYPES map in ai-chat-content.mjs */ export const AGENT_UI_TYPES = Object.freeze({ MONITOR_ITEM: "agent-monitor-item", }); /** * updateType values dispatched by agent cards * Kept in sync with the UI_UPDATE_TYPES map in ai-chat-content.mjs */ export const AGENT_UPDATE_TYPES = Object.freeze({ CREATE_MONITOR: "create-monitor", CANCEL_MONITOR: "cancel-monitor", UPDATE_MONITOR: "update-monitor", DELETE_MONITOR: "delete-monitor", PAUSE_MONITOR: "pause-monitor", CHECK_MONITOR: "check-monitor", }); export const AGENT_COMMANDS = Object.freeze({ MONITOR: "monitor", }); // Default cadence for a newly created monitor const DEFAULT_MONITOR_CHECK_MINUTES = 60; const PREF_AGENT_ENABLED = "browser.smartwindow.agent.enabled"; /** * Handles interactive updates from agent cards embedded in chat */ export class AgentUI { /** * Routes an agent card update to its handler * * @param {AgentUpdateData} data * @param {object} conversation - The active ChatConversation * @param {ChromeWindow} window - The browser window * @returns {Promise} True when the update was handled */ static async handleUpdate(data, conversation, window) { const { messageId, toolCallId, updateType, updateData } = data ?? {}; if (!messageId) { return false; } const message = conversation?.messages?.find(m => m.id === messageId); // Agents aren't tool calls, but the card reuses 'toolCallId' as its correlation handle if (message?.toolUIData?.toolCallId !== toolCallId) { return false; } const handler = this.#UPDATE_TYPE_HANDLERS[updateType]; if (typeof handler !== "function") { lazy.console.error(`AgentUI: unknown updateType "${updateType}"`); return false; } return handler({ message, updateData, conversation, window }); } /** * New agents register their update types here * * @private */ static #UPDATE_TYPE_HANDLERS = { [AGENT_UPDATE_TYPES.CREATE_MONITOR]: this.handleCreateMonitor.bind(this), [AGENT_UPDATE_TYPES.CANCEL_MONITOR]: this.#handleCancelMonitor.bind(this), [AGENT_UPDATE_TYPES.UPDATE_MONITOR]: this.#handleUpdateMonitor.bind(this), [AGENT_UPDATE_TYPES.DELETE_MONITOR]: this.#handleDeleteMonitor.bind(this), [AGENT_UPDATE_TYPES.PAUSE_MONITOR]: this.#handlePauseMonitor.bind(this), [AGENT_UPDATE_TYPES.CHECK_MONITOR]: this.#handleCheckMonitor.bind(this), }; /** * New agents register their smartbar commands here * * @private */ static #COMMAND_HANDLERS = { [AGENT_COMMANDS.MONITOR]: this.#handleMonitorCommand.bind(this), }; /** * Handles the monitor create card for the current page * * @param {AgentCommandContext} context * @param {string} context.text - The command argument text, with the `/monitor` prefix stripped * @param {string} [context.contextPageUrl] - Url of the page the command was issued from * @param {Conversation} context.conversation - The conversation the command was submitted in */ static async #handleMonitorCommand({ text, contextPageUrl, conversation }) { if (!conversation) { return; } const { prompt: condition, raw } = text; const url = contextPageUrl || ""; if (raw) { const userMessage = conversation.addUserMessage(raw); conversation.emit("chat-conversation:message-update", userMessage); } const monitors = await lazy.MonitorAgent.listMonitors(); if (monitors.length >= lazy.TOTAL_NUM_MONITORS) { // TODO: Bug 2054529 - localize this string conversation.addAssistantMessage( "text", `You've hit the limit of ${lazy.TOTAL_NUM_MONITORS} monitors. Remove one at about:tools to start a new one.` ); return; } // TODO: Bug 2054529 - localize this string conversation.addAssistantMessage( "text", "Looks like a product page — I've set this up to watch the price. Tweak anything, then start it." ); conversation.addUIToolToCurrentMessage(`monitor-${crypto.randomUUID()}`, { uiType: AGENT_UI_TYPES.MONITOR_ITEM, properties: { agent: { condition, url, watchUrls: url ? [url] : [], }, }, }); } /** * Creates a monitor from the card's submitted condition * plus the page context that seeded the card * * @param {AgentHandlerContext} context * @returns {Promise} */ static async handleCreateMonitor({ message, updateData, conversation }) { const agent = message?.toolUIData?.properties?.agent ?? {}; const args = this.#buildMonitorArgs(updateData, agent); if (!args.prompt || !args.watchUrls.length) { lazy.console.warn( "AgentUI: cannot create a monitor without a prompt and at least one URL" ); return false; } let monitorId; try { monitorId = await lazy.MonitorAgent.createMonitor(args); } catch (error) { lazy.console.error("AgentUI: failed to create monitor", error); return false; } // TODO: Bug 2054529 - localize this string const monitorName = args.pageTitle || "this page"; message.content.body = `Watching ${monitorName}. You'll hear from me if ${args.prompt} — it lives at about:tools.`; message.toolUIData = { ...message.toolUIData, properties: { mode: "display", agent: { id: monitorId, monitorName, url: args.watchUrls[0] ?? "", watchUrls: args.watchUrls, condition: args.prompt, status: { label: "Watching", kind: "watching" }, schedule: updateData?.schedule, }, }, }; conversation.emit("chat-conversation:message-update", message); // Mark the message complete so it renders the assistant footer conversation.emit("chat-conversation:message-complete", message); return true; } /** * Dismisses the "create" card without persisting anything * * @param {AgentHandlerContext} context * @returns {Promise} */ static async #handleCancelMonitor({ message, conversation }) { await conversation.updateToolUI(message, null, null); return true; } /** * Saves edits from the display card back to an existing monitor * * @param {AgentHandlerContext} context * @returns {Promise} */ static async #handleUpdateMonitor({ message, updateData, conversation }) { const id = updateData?.id; if (!id) { lazy.console.warn("AgentUI: cannot update a monitor without an id"); return false; } try { await lazy.MonitorAgent.updateMonitor(id, { monitorPrompt: updateData.condition, watchUrls: updateData.watchUrls, title: updateData.monitorName, schedule: this.#buildSchedule(updateData.schedule), }); } catch (error) { lazy.console.error("AgentUI: failed to update monitor", error); return false; } const agent = message?.toolUIData?.properties?.agent ?? {}; message.toolUIData = { ...message.toolUIData, properties: { mode: "display", agent: { ...agent, monitorName: updateData.monitorName || agent.monitorName, condition: updateData.condition, url: updateData.watchUrls?.[0] ?? agent.url, watchUrls: updateData.watchUrls, schedule: updateData.schedule, }, }, }; conversation.emit("chat-conversation:message-update", message); return true; } /** * Deletes an existing monitor and removes its card * * @param {AgentHandlerContext} context * @returns {Promise} */ static async #handleDeleteMonitor({ message, updateData, conversation }) { const id = updateData?.id; if (!id) { lazy.console.warn("AgentUI: cannot delete a monitor without an id"); return false; } try { await lazy.MonitorAgent.deleteMonitor(id); } catch (error) { lazy.console.error("AgentUI: failed to delete monitor", error); return false; } await conversation.updateToolUI(message, null, null); return true; } /** * Toggles an existing monitor between paused (disabled) and watching * (enabled) * * @param {AgentHandlerContext} context * @returns {Promise} */ static async #handlePauseMonitor({ message, updateData, conversation }) { const id = updateData?.id; if (!id) { lazy.console.warn("AgentUI: cannot pause a monitor without an id"); return false; } const paused = !!updateData?.paused; try { await lazy.MonitorAgent.updateMonitor(id, { enabled: !paused }); } catch (error) { lazy.console.error("AgentUI: failed to pause monitor", error); return false; } const agent = message?.toolUIData?.properties?.agent ?? {}; // TODO: Bug 2054529 - localize these strings const status = paused ? { label: "Paused", kind: "paused" } : { label: "Watching", kind: "watching" }; message.toolUIData = { ...message.toolUIData, properties: { ...message.toolUIData.properties, agent: { ...agent, status }, }, }; conversation.emit("chat-conversation:message-update", message); return true; } /** * Runs an existing monitor's check immediately then reconciles the card's * history with the monitor's notification events * * @param {AgentHandlerContext} context * @returns {Promise} */ static async #handleCheckMonitor({ conversation, updateData }) { const id = updateData?.id; if (!id) { lazy.console.warn("AgentUI: cannot check a monitor without an id"); return false; } try { await lazy.MonitorAgent.runNow(id); } catch (error) { lazy.console.error("AgentUI: failed to run monitor", error); return false; } // The manual run fires MONITOR_AGENTS_CHANGED_TOPIC but sync this // conversation directly so the card updates even when it is not observed const byId = await this.#loadMonitorsById(); this.#syncConversationHistory(conversation, byId); return true; } /** * Registers a conversation to have its monitor cards kept in sync with * MonitorAgent's notification history * * @param {object} conversation - The active ChatConversation */ static observeMonitorChanges(conversation) { if (!conversation) { return; } this.#observedConversations.add(conversation); if (!this.#monitorObserver) { this.#monitorObserver = () => { this.#syncAllMonitorHistories().catch(error => lazy.console.error("AgentUI: failed to sync monitor histories", error) ); }; Services.obs.addObserver( this.#monitorObserver, lazy.MONITOR_AGENTS_CHANGED_TOPIC ); } this.#syncAllMonitorHistories().catch(error => lazy.console.error("AgentUI: failed to sync monitor histories", error) ); } /** * Stops syncing a conversation's monitor cards * * @param {object} conversation - The ChatConversation to stop observing */ static unobserveMonitorChanges(conversation) { if (!conversation) { return; } this.#observedConversations.delete(conversation); if (!this.#observedConversations.size && this.#monitorObserver) { Services.obs.removeObserver( this.#monitorObserver, lazy.MONITOR_AGENTS_CHANGED_TOPIC ); this.#monitorObserver = null; } } /** @type {Set} Conversations whose monitor cards we keep in sync */ static #observedConversations = new Set(); /** @type {?Function} Shared MONITOR_AGENTS_CHANGED_TOPIC observer */ static #monitorObserver = null; static async #loadMonitorsById() { const monitors = await lazy.MonitorAgent.listMonitors(); return new Map(monitors.map(monitor => [monitor.id, monitor])); } static async #syncAllMonitorHistories() { if (!this.#observedConversations.size) { return; } const byId = await this.#loadMonitorsById(); for (const conversation of this.#observedConversations) { this.#syncConversationHistory(conversation, byId); } } /** * Rebuilds each monitor card's history from its monitor's notification * entries and re-renders when it changed * * @param {object} conversation - The ChatConversation to reconcile * @param {Map} byId - Serialized monitors keyed by id * @private */ static #syncConversationHistory(conversation, byId) { for (const message of conversation?.messages ?? []) { if (message?.toolUIData?.uiType !== AGENT_UI_TYPES.MONITOR_ITEM) { continue; } const agent = message.toolUIData.properties?.agent; const monitor = byId.get(agent?.id); if (!monitor) { continue; } const history = (monitor.history ?? []) .filter(entry => entry.status === "success" || entry.status === "error") .map(entry => this.#toHistoryRow(entry)) .reverse(); if (JSON.stringify(history) === JSON.stringify(agent.history ?? [])) { continue; } message.toolUIData = { ...message.toolUIData, properties: { ...message.toolUIData.properties, agent: { ...agent, history }, }, }; conversation.emit("chat-conversation:message-update", message); } } /** * Maps a completed monitor run entry to a card history row * * @param {object} entry - A monitor history entry '{ checkedAt, status, * conditionMet, ... }' * @returns {{ when: string, flag?: string, note?: string, low?: boolean }} * @private */ static #toHistoryRow(entry) { const when = this.#formatCheckedAt(entry.checkedAt); // TODO: Bug 2054529 - localize these strings if (entry.status === "error") { return { when, note: "Check failed. Check again later.", low: true }; } if (entry.conditionMet) { return { when, flag: "notified" }; } return { when, note: "Checked, didn't meet your alert. Check again later.", low: true, }; } /** * Formats a run's timestamp * * @param {string} [iso] - ISO timestamp of the run * @returns {string} * @private */ static #formatCheckedAt(iso) { const date = iso ? new Date(iso) : new Date(); const now = new Date(); // TODO: Bug 2054529 - localize these strings if (now - date < 60000) { return "Just now"; } if (date.toDateString() === now.toDateString()) { return date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", }); } return date.toLocaleDateString([], { month: "short", day: "numeric" }); } /** * Maps a monitor card's submit payload plus the page context that seeded it * to createMonitor arguments * * @param {object} updateData - '{ condition, ... }' from the card submit event * @param {object} agent - The agent data that seeded the card * @returns {{ prompt: string, watchUrls: string[], pageTitle: string, schedule: object }} * @private */ static #buildMonitorArgs(updateData, agent) { const watchUrls = updateData?.watchUrls?.length ? updateData.watchUrls : (agent.watchUrls ?? (agent.url ? [agent.url] : [])); return { prompt: updateData?.condition ?? agent.condition ?? "", watchUrls, pageTitle: updateData?.monitorName || agent.monitorName || agent.pageTitle || "", schedule: this.#buildSchedule(updateData?.schedule), }; } /** * Builds a Schedule from the card's * * @param {object} [schedule] - '{ frequency: "daily"|"weekly", time: "HH:MM", * weekday: string }' from the card submit event * @returns {object} An IntervalSchedule, DailySchedule or WeeklySchedule * @private */ static #buildSchedule(schedule) { const [hour, minute] = String(schedule?.time ?? "") .split(":") .map(Number); switch (schedule?.frequency) { case "daily": return new lazy.DailySchedule(hour, minute); case "weekly": return new lazy.WeeklySchedule(Number(schedule.weekday), hour, minute); default: return new lazy.IntervalSchedule(DEFAULT_MONITOR_CHECK_MINUTES); } } /** * Parses a leading command keyword from chat classified smartbar input as an * interim stand in for the "/" command palette * * @param {string} value - Raw chat input * @returns {?{command: string, prompt: string, raw: string}} The lowercased * command keyword, the text following the command, and the raw chat input, * or null when there is no leading command * @private */ static #parseCommand(value) { const raw = String(value ?? "").trim(); const match = /^\/(\w+)\b\s*(.*)$/s.exec(raw); if (!match) { return null; } return { command: match[1].toLowerCase(), prompt: match[2].trim(), raw, }; } /** * Routes a smartbar submission to an agent when it is an agent command * @param {object} context * @param {string} [context.command] - Explicit command id from the palette * @param {string} context.value - Raw smartbar input * @param {string} [context.contextPageUrl] - Current page URL to seed the agent * @param {object} context.conversation - The active ChatConversation * @param {ChromeWindow} [context.window] - The browser window * @returns {boolean} True when the input was recognized and handled as a command */ static tryHandleCommand({ command, value, contextPageUrl, conversation, window, }) { if (!Services.prefs.getBoolPref(PREF_AGENT_ENABLED, false)) { return false; } // An explicit command from the palette takes priority const parsed = this.#parseCommand(value); const parsedCommand = command ? { command, prompt: parsed?.prompt ?? "", raw: String(value ?? "").trim(), } : parsed; if (!parsedCommand) { return false; } const handler = this.#COMMAND_HANDLERS[parsedCommand.command]; if (typeof handler !== "function") { return false; } handler({ text: parsedCommand, contextPageUrl, conversation, window, }); return true; } /** * Route the given tool-UI update targets an agent card * * @param {AgentUpdateData} data - The tool-UI update payload * @returns {boolean} True when AgentUI has a handler for this update type */ static isAgentUpdate(data) { return typeof this.#UPDATE_TYPE_HANDLERS[data?.updateType] === "function"; } }