Source code

Revision control

Copy as Markdown

Other Tools

/* 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<boolean>} 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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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<object>} 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<string, object>} 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";
}
}