tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 7793bcad62947819304c839402dd546491b30a79
parent e63b083d46f9cef1794222f77d13b1dc6124eaa9
Author: Omar Gonzalez <s9tpepper@apache.org>
Date:   Fri, 12 Dec 2025 05:11:42 +0000

Bug 2000961 - ChatStore module for ai window persistence r=mak,Mardak,ai-frontend-reviewers

Differential Revision: https://phabricator.services.mozilla.com/D274644

Diffstat:
Mbrowser/app/profile/firefox.js | 1+
Mbrowser/base/content/test/static/browser_all_files_referenced.js | 4++++
Abrowser/components/aiwindow/ui/modules/ChatConstants.sys.mjs | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/modules/ChatConversation.sys.mjs | 248+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/modules/ChatMessage.sys.mjs | 213+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/modules/ChatMigrations.sys.mjs | 47+++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/modules/ChatSql.sys.mjs | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/modules/ChatStore.sys.mjs | 798+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/modules/ChatUtils.sys.mjs | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/ui/moz.build | 9+++++++++
Abrowser/components/aiwindow/ui/test/xpcshell/asserts.js | 256+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/xpcshell/test_ChatConversation.js | 412+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/xpcshell/test_ChatMessage.js | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/xpcshell/test_ChatStore.js | 613+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/xpcshell/test_chat-utils.js | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abrowser/components/aiwindow/ui/test/xpcshell/xpcshell.toml | 14++++++++++++++
16 files changed, 3377 insertions(+), 0 deletions(-)

diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -2246,6 +2246,7 @@ pref("browser.ml.smartAssist.overrideNewTab", false); // AI Window Feature pref("browser.aiwindow.enabled", false); +pref("browser.aiwindow.chatStore.loglevel", "Error"); // Block insecure active content on https pages pref("security.mixed_content.block_active_content", true); diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js @@ -335,6 +335,10 @@ var allowlist = [ { file: "moz-src:///browser/components/aiwindow/models/IntentClassifier.sys.mjs", }, + // Bug 2000961 - Add ChatStore.sys.mjs module + { + file: "moz-src:///browser/components/aiwindow/ui/modules/ChatStore.sys.mjs", + }, // Bug 2003598 - Add Chat service with fetch with history (backed out due to unused file) { file: "moz-src:///browser/components/aiwindow/models/Chat.sys.mjs", diff --git a/browser/components/aiwindow/ui/modules/ChatConstants.sys.mjs b/browser/components/aiwindow/ui/modules/ChatConstants.sys.mjs @@ -0,0 +1,67 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +/** + * The current SQLite database schema version + */ +export const CURRENT_SCHEMA_VERSION = 1; + +/** + * The directory that the SQLite database lives in + */ +export const DB_FOLDER_PATH = PathUtils?.profileDir ?? "./"; + +/** + * The name of the SQLite database file + */ +export const DB_FILE_NAME = "chat-store.sqlite"; + +/** + * Preference branch for the Chat storage location + */ +export const PREF_BRANCH = "browser.aiWindow.chatHistory"; + +/** + * @typedef ConversationStatus + * @property {number} ACTIVE - An active conversation + * @property {number} ARCHIVE - An archived conversation + * @property {number} DELETED - A deleted conversation + */ + +/** + * @type {ConversationStatus} + */ +export const CONVERSATION_STATUS = Object.freeze({ + ACTIVE: 0, + ARCHIVED: 1, + DELETED: 2, +}); + +/** + * @typedef {0 | 1 | 2 | 3} MessageRole + */ + +/** + * @enum {MessageRole} + */ +export const MESSAGE_ROLE = Object.freeze({ + USER: 0, + ASSISTANT: 1, + SYSTEM: 2, + TOOL: 3, +}); + +/** + * @typedef {0 | 1 | 2} InsightsFlagSource + */ + +/** + * @type {InsightsFlagSource} + */ +export const INSIGHTS_FLAG_SOURCE = Object.freeze({ + GLOBAL: 0, + CONVERSATION: 1, + MESSAGE_ONCE: 2, +}); diff --git a/browser/components/aiwindow/ui/modules/ChatConversation.sys.mjs b/browser/components/aiwindow/ui/modules/ChatConversation.sys.mjs @@ -0,0 +1,248 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +import { makeGuid } from "./ChatUtils.sys.mjs"; +import { CONVERSATION_STATUS, MESSAGE_ROLE } from "./ChatConstants.sys.mjs"; +import { + AssistantRoleOpts, + ChatMessage, + ToolRoleOpts, + UserRoleOpts, +} from "./ChatMessage.sys.mjs"; + +/** + * A conversation containing messages. + */ +export class ChatConversation { + id; + title; + description; + pageUrl; + pageMeta; + createdDate; + updatedDate; + status; + #messages; + activeBranchTipMessageId; + + /** + * @param {object} params + * @param {string} [params.id] + * @param {string} params.title + * @param {string} params.description + * @param {URL} params.pageUrl + * @param {object} params.pageMeta + * @param {number} [params.createdDate] + * @param {number} [params.updatedDate] + * @param {CONVERSATION_STATUS} [params.status] + * @param {Array<ChatMessage>} [params.messages] + */ + constructor(params) { + const { + id = makeGuid(), + title, + description, + pageUrl, + pageMeta, + createdDate = Date.now(), + updatedDate = Date.now(), + messages = [], + } = params; + + this.id = id; + this.title = title; + this.description = description; + this.pageUrl = pageUrl; + this.pageMeta = pageMeta; + this.createdDate = createdDate; + this.updatedDate = updatedDate; + this.#messages = messages; + + // NOTE: Destructuring params.status causes a linter error + this.status = params.status || CONVERSATION_STATUS.ACTIVE; + } + + /** + * Adds a message to the conversation + * + * @param {ConversationRole} role - The type of conversation message + * @param {object} content - The conversation message contents + * @param {URL} pageUrl - The current page url when message was submitted + * @param {number} turnIndex - The current conversation turn/cycle + * @param {AssistantRoleOpts|ToolRoleOpts|UserRoleOpts} opts - Additional opts for the message + */ + addMessage(role, content, pageUrl, turnIndex, opts = {}) { + if (role < 0 || role > MESSAGE_ROLE.TOOL) { + return; + } + + if (turnIndex < 0) { + turnIndex = 0; + } + + let parentMessageId = null; + if (this?.messages?.length) { + const lastMessageIndex = this.messages.length - 1; + parentMessageId = this.messages[lastMessageIndex].id; + } + + const convId = this.id; + const currentMessages = this?.messages || []; + const ordinal = currentMessages.length ? currentMessages.length + 1 : 1; + + const message_data = { + parentMessageId, + content, + ordinal, + pageUrl, + turnIndex, + role, + convId, + ...opts, + }; + + const newMessage = new ChatMessage(message_data); + + this.messages.push(newMessage); + } + + /** + * Add a user message to the conversation + * + * @todo Bug 2005424 + * Limit/filter out data uris from message data + * + * @param {string} contentBody - The user message content + * @param {string?} [pageUrl=""] - The current page url when message was submitted + * @param {number?} [turnIndex=0] - The conversation turn/cycle + * @param {UserRoleOpts} [userOpts=new UserRoleOpts()] - User message options + */ + addUserMessage( + contentBody, + pageUrl = "", + turnIndex = 0, + userOpts = new UserRoleOpts() + ) { + const content = { + type: "text", + body: contentBody, + }; + + let url = URL.parse(pageUrl); + + this.addMessage(MESSAGE_ROLE.USER, content, url, turnIndex, userOpts); + } + + /** + * Add an assistant message to the conversation + * + * @param {string} type - The assistant message type: text|function + * @param {string} contentBody - The assistant message content + * @param {number} turnIndex - The current conversation turn/cycle + * @param {AssistantRoleOpts} [assistantOpts=new AssistantRoleOpts()] - ChatMessage options specific to assistant messages + */ + addAssistantMessage( + type, + contentBody, + turnIndex, + assistantOpts = new AssistantRoleOpts() + ) { + const content = { + type: "text", + body: contentBody, + }; + + this.addMessage( + MESSAGE_ROLE.ASSISTANT, + content, + "", + turnIndex, + assistantOpts + ); + } + + /** + * Add a tool call message to the conversation + * + * @param {object} content - The tool call object to be saved as JSON + * @param {number} turnIndex - The current conversation turn/cycle + * @param {ToolRoleOpts} [toolOpts=new ToolRoleOpts()] - Message opts for a tool role message + */ + addToolCallMessage(content, turnIndex, toolOpts = new ToolRoleOpts()) { + this.addMessage(MESSAGE_ROLE.TOOL, content, "", turnIndex, toolOpts); + } + + /** + * Add a system message to the conversation + * + * @param {string} type - The assistant message type: text|injected_insights|injected_real_time_info + * @param {string} contentBody - The system message object to be saved as JSON + * @param {number} turnIndex - The current conversation turn/cycle + */ + addSystemMessage(type, contentBody, turnIndex) { + const content = { type, body: contentBody }; + + this.addMessage(MESSAGE_ROLE.SYSTEM, content, "", turnIndex); + } + + /** + * Retrieves the list of visited sites during a conversation in visited order. + * Primarily used to retrieve external URLs that the user had a conversation + * around to display in Chat History view. + * + * @param {boolean} [includeInternal=false] - Whether to include internal Firefox URLs + * + * @returns {Array<URL>} - Ordered list of visited page URLs for this conversation + */ + getSitesList(includeInternal = false) { + const seen = new Set(); + const deduped = []; + + this.messages.forEach(message => { + if (!message.pageUrl) { + return; + } + + if (!includeInternal && !message.pageUrl.protocol.startsWith("http")) { + return; + } + + if (!seen.has(message.pageUrl.href)) { + seen.add(message.pageUrl.href); + deduped.push(message.pageUrl); + } + }); + + return deduped; + } + + /** + * Returns the most recently visited external sites during this conversation, or null + * if no external sites have been visited. + * + * @returns {URL|null} + */ + getMostRecentPageVisited() { + const sites = this.getSitesList(); + + return sites.length ? sites.pop() : null; + } + + #updateActiveBranchTipMessageId() { + this.activeBranchTipMessageId = this.messages + .filter(m => m.isActiveBranch) + .sort((a, b) => b.ordinal - a.ordinal) + .shift()?.id; + } + + set messages(value) { + this.#messages = value; + this.#updateActiveBranchTipMessageId(); + } + + get messages() { + return this.#messages; + } +} diff --git a/browser/components/aiwindow/ui/modules/ChatMessage.sys.mjs b/browser/components/aiwindow/ui/modules/ChatMessage.sys.mjs @@ -0,0 +1,213 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +import { makeGuid } from "./ChatUtils.sys.mjs"; + +/** + * A message in a conversation. + */ +export class ChatMessage { + id; + createdDate; + parentMessageId; + revisionRootMessageId; + ordinal; + isActiveBranch; + role; + modelId; + params; + usage; + content; + convId; + pageUrl; + turnIndex; + insightsEnabled; + insightsFlagSource; + insightsApplied; + webSearchQueries; + + /** + * @param {object} param + * @param {number} param.ordinal - The order of the message + * @param {MessageRole} param.role - The message role + * @param {object} param.content - The message content object + * @param {number} param.turnIndex - The message turn, different than ordinal, + * prompt/reply for example would be one turn + * @param {string} [param.pageUrl = null] - A URL object defining which page + * the user was on when submitting a message if role == user + * @param {string} [param.id = makeGuid()] - The row.message_id of the + * message in the database + * @param {number} [param.createdDate = Date.now()] - The date the message was + * sent/stored in the database + * @param {string} [param.parentMessageId = null] - The id of the message + * which came before this message when it was added to the conversation, + * null if its the first message of the converation + * @param {string} [param.convId = null] - The id of the conversation the + * message belongs to + * @param {?boolean} param.insightsEnabled - Whether insights were enabled + * when the message was submitted if role == assistant + * @param {InsightsFlagSource} param.insightsFlagSource - How the + * insightsEnabled flag was determined if role == assistant, one of + * INSIGHTS_FLAG_SOURCE.GLOBAL, INSIGHTS_FLAG_SOURCE.CONVERSATION, + * INSIGHTS_FLAG_SOURCE.MESSAGE_ONCE + * @param {?Array<string>} param.insightsApplied - List of strings of insights + * that were applied to a response if insightsEnabled == true + * @param {?Array<string>} param.webSearchQueries - List of strings of web + * search queries that were applied to a response if role == assistant + * @param {object} [param.params = null] - Model params used if role == assistant|tool + * @param {object} [param.usage = null] - Token usage data for the current + * response if role == assistant + * @param {string} [param.modelId = null] - The model used for content + * generation if role == assistant|tool + * @param {string} [param.revisionRootMessageId = id] - Reference to the root + * of this branch, which ID a message branched from. Should be set to the + * same value as id when a message is first created. If a message is + * edited/regenerated revisionRootMessageId should remain the same for + * subsequent edits/regenerations, the id would diverge for subsequent + * edits/regenerations. + * @param {boolean} [param.isActiveBranch = true] - Defaults to true when a + * message is originally generated. If a message is edited/regenerated, the + * edited message turns to false and the newly edited/regenerated message is + * the only message of the revision branch set to true. + */ + constructor({ + ordinal, + role, + content, + turnIndex, + pageUrl = null, + id = makeGuid(), + createdDate = Date.now(), + parentMessageId = null, + convId = null, + insightsEnabled = null, + insightsFlagSource = null, + insightsApplied = null, + webSearchQueries = null, + params = null, + usage = null, + modelId = null, + revisionRootMessageId = id, + isActiveBranch = true, + }) { + this.id = id; + this.createdDate = createdDate; + this.parentMessageId = parentMessageId; + this.revisionRootMessageId = revisionRootMessageId; + this.isActiveBranch = isActiveBranch; + this.ordinal = ordinal; + this.role = role; + this.modelId = modelId; + this.params = params; + this.usage = usage; + this.content = content; + this.convId = convId; + this.pageUrl = pageUrl ? new URL(pageUrl) : null; + this.turnIndex = turnIndex; + this.insightsEnabled = insightsEnabled; + this.insightsFlagSource = insightsFlagSource; + this.insightsApplied = insightsApplied; + this.webSearchQueries = webSearchQueries; + } +} + +/** + * Options required for a conversation message with + * role of assistant + */ +export class AssistantRoleOpts { + insightsEnabled; + insightsFlagSource; + insightsApplied; + webSearchQueries; + params; + usage; + modelId; + + /** + * @param {string} [modelId=null] + * @param {object} [params=null] - The model params used + * @param {object} [usage=null] - Token usage data for the current response + * @param {boolean} [insightsEnabled=false] - Whether insights were enabled when the message was submitted + * @param {import("moz-src:///browser/components/aiwindow/ui/modules/ChatStorage.sys.mjs").InsightsFlagSource} [insightsFlagSource=null] - How the insightsEnabled flag was determined + * @param {?Array<string>} [insightsApplied=[]] - List of strings of insights that were applied to a response + * @param {?Array<string>} [webSearchQueries=[]] - List of strings of web search queries that were applied to a response + */ + constructor( + modelId = null, + params = null, + usage = null, + insightsEnabled = false, + insightsFlagSource = null, + insightsApplied = [], + webSearchQueries = [] + ) { + this.insightsEnabled = insightsEnabled; + this.insightsFlagSource = insightsFlagSource; + this.insightsApplied = insightsApplied; + this.webSearchQueries = webSearchQueries; + this.params = params; + this.usage = usage; + this.modelId = modelId; + } +} + +/** + * Options required for a conversation message with + * role of assistant + */ +export class ToolRoleOpts { + modelId; + + /** + * @param {string} [modelId=null] + */ + constructor(modelId = null) { + this.modelId = modelId; + } +} + +/** + * Options required for a conversation message with + * role of user + */ +export class UserRoleOpts { + revisionRootMessageId; + + /** + * @param {string} [revisionRootMessageId=undefined] + */ + constructor(revisionRootMessageId) { + if (revisionRootMessageId) { + this.revisionRootMessageId = revisionRootMessageId; + } + } +} + +/** + * Used to retrieve chat entries for the History app menu + */ +export class ChatMinimal { + #id; + #title; + + /** + * @param {object} params + * @param {string} params.convId + * @param {string} params.title + */ + constructor({ convId, title }) { + this.#id = convId; + this.#title = title; + } + + get id() { + return this.#id; + } + + get title() { + return this.#title; + } +} diff --git a/browser/components/aiwindow/ui/modules/ChatMigrations.sys.mjs b/browser/components/aiwindow/ui/modules/ChatMigrations.sys.mjs @@ -0,0 +1,47 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +/** + * Please refer to sql.mjs for details on creating new migrations. + * + * - List each change here and what it's for. + * + * @param {OpenedConnection} _conn - The SQLite connection to use for the migration + * @param {number} _version - The version number of the current schema + */ +async function applyV2(_conn, _version) { + // if (version < 2) { + // // Drop the summary table, it is no longer needed + // await conn.execute(`DROP TABLE IF EXISTS summary;`); + // + // try { + // await conn.execute("SELECT page_url FROM message LIMIT 1;"); + // } catch (e) { + // // Add a page_url column to the message table to keep track + // // of the pages visitedjduring a conversation and when they + // // were visited + // await conn.execute(` + // ALTER TABLE message ADD COLUMN page_url TEXT; + // `); + // } + // + // try { + // await conn.execute("SELECT turn_index FROM message LIMIT 1;"); + // } catch (e) { + // // Add a turn_index column to group all types of messages into a + // // particular turn, ex: prompt -> response would be a single turn. + // await conn.execute(` + // ALTER TABLE message ADD COLUMN turn_index INTEGER; + // `); + // } + // } +} + +/** + * Array of migration functions to run in the order they should be run in. + * + * @returns {Array<Function>} + */ +export const migrations = [applyV2]; diff --git a/browser/components/aiwindow/ui/modules/ChatSql.sys.mjs b/browser/components/aiwindow/ui/modules/ChatSql.sys.mjs @@ -0,0 +1,244 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +// Every time the schema or the underlying data changes, you must bump up the +// schema version. + +// Remember to: +// 1. Bump up the version number +// 2. Add a migration function to migrate the data to the new schema. +// 3. Update #createDatabaseEntities and #checkDatabaseHealth +// 4. Add a test to check that the migration works correctly. + +// Note: migrations should be reasonably re-entry-friendly. If the user +// downgrades, the schema version is decreased, and upon a subsequent upgrade, +// the migration step is reapplied. +// This ensures that any necessary conversions are performed, even for entries +// added after the downgrade. +// In practice, schema changes should be additive, allowing newer versions to +// operate on older schemas, albeit with potentially reduced functionality. + +export const ESCAPE_CHAR = "/"; + +export const CONVERSATION_TABLE = ` +CREATE TABLE conversation ( + conv_id TEXT PRIMARY KEY, + title TEXT, + description TEXT, + page_url TEXT, + page_meta_jsonb BLOB, + created_date INTEGER NOT NULL, + updated_date INTEGER NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + active_branch_tip_message_id TEXT -- no foreign here, as we insert messages later. +) WITHOUT ROWID; +`; + +export const CONVERSATION_UPDATED_DATE_INDEX = ` +CREATE INDEX conversation_updated_date_idx ON conversation(updated_date); +`; + +export const MESSAGE_TABLE = ` +CREATE TABLE message ( + message_id TEXT PRIMARY KEY, + conv_id TEXT NOT NULL REFERENCES conversation(conv_id) ON DELETE CASCADE, + created_date INTEGER NOT NULL, + parent_message_id TEXT REFERENCES message(message_id) ON DELETE CASCADE, + revision_root_message_id TEXT REFERENCES message(message_id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL CHECK(ordinal >= 0), + is_active_branch INTEGER NOT NULL, + role INTEGER NOT NULL, + model_id TEXT, + params_jsonb BLOB, + content_jsonb BLOB, + usage_jsonb BLOB, + page_url TEXT, + turn_index INTEGER, + insights_enabled BOOLEAN, + insights_flag_source INTEGER, + insights_applied_jsonb BLOB, + web_search_queries_jsonb BLOB +) WITHOUT ROWID; +`; + +export const MESSAGE_ORDINAL_INDEX = ` +CREATE INDEX message_ordinal_idx ON message(ordinal); +`; + +// @todo Bug 2005423 +// Maybe add hashed url column to optimize message_url_idx +export const MESSAGE_URL_INDEX = ` +CREATE INDEX message_url_idx ON message(page_url); +`; + +export const MESSAGE_CREATED_DATE_INDEX = ` +CREATE INDEX message_created_date_idx ON message(created_date); +`; + +export const CONVERSATION_INSERT = ` +INSERT INTO conversation ( + conv_id, title, description, page_url, page_meta_jsonb, + created_date, updated_date, status, active_branch_tip_message_id +) VALUES ( + :conv_id, :title, :description, :page_url, jsonb(:page_meta), + :created_date, :updated_date, :status, :active_branch_tip_message_id +) +ON CONFLICT(conv_id) DO UPDATE + SET title = :title, + updated_date = :updated_date, + status = :status, + active_branch_tip_message_id = :active_branch_tip_message_id; +`; + +export const MESSAGE_INSERT = ` +INSERT INTO message ( + message_id, conv_id, created_date, parent_message_id, + revision_root_message_id, ordinal, is_active_branch, role, + model_id, params_jsonb, content_jsonb, usage_jsonb, page_url, turn_index, + insights_enabled, insights_flag_source, insights_applied_jsonb, + web_search_queries_jsonb +) VALUES ( + :message_id, :conv_id, :created_date, :parent_message_id, + :revision_root_message_id, :ordinal, :is_active_branch, :role, + :model_id, jsonb(:params), jsonb(:content), jsonb(:usage), :page_url, :turn_index, + :insights_enabled, :insights_flag_source, jsonb(:insights_applied_jsonb), + jsonb(:web_search_queries_jsonb) +) +ON CONFLICT(message_id) DO UPDATE SET + is_active_branch = :is_active_branch; +`; + +export const CONVERSATIONS_MOST_RECENT = ` +SELECT conv_id, title +FROM conversation +ORDER BY updated_date DESC +LIMIT :limit; +`; + +export const CONVERSATIONS_OLDEST = ` +SELECT conv_id, title +FROM conversation +ORDER BY updated_date ASC +LIMIT :limit; +`; + +export const CONVERSATION_BY_ID = ` +SELECT conv_id, title, description, page_url, + json(page_meta_jsonb) AS page_meta, created_date, updated_date, + status, active_branch_tip_message_id +FROM conversation WHERE conv_id = :conv_id; +`; + +export const CONVERSATIONS_BY_DATE = ` +SELECT conv_id, title, description, page_url, + json(page_meta_jsonb) AS page_meta, created_date, updated_date, + status, active_branch_tip_message_id +FROM conversation +WHERE updated_date >= :start_date AND updated_date <= :end_date +ORDER BY updated_date DESC; +`; + +export const CONVERSATIONS_BY_URL = ` +SELECT c.conv_id, c.title, c.description, c.page_url, + json(c.page_meta_jsonb) AS page_meta, c.created_date, c.updated_date, + c.status, c.active_branch_tip_message_id +FROM conversation c +WHERE EXISTS ( + SELECT 1 + FROM message m + WHERE m.conv_id = c.conv_id + AND m.page_url = :page_url +) +ORDER BY c.updated_date DESC; +`; + +/** + * Get all messages for multiple conversations + * + * @param {number} amount - The number of conversation IDs to get messages for + */ +export function getConversationMessagesSql(amount) { + return ` + SELECT + message_id, created_date, parent_message_id, revision_root_message_id, + ordinal, is_active_branch, role, model_id, conv_id, + json(params_jsonb) AS params, json(usage_jsonb) AS usage, + page_url, turn_index, insights_enabled, insights_flag_source, + json(insights_applied_jsonb) AS insights_applied, + json(web_search_queries_jsonb) AS web_search_queries, + json(content_jsonb) AS content + FROM message + WHERE conv_id IN(${new Array(amount).fill("?").join(",")}) + ORDER BY ordinal ASC; + `; +} + +export const CONVERSATIONS_CONTENT_SEARCH = ` +SELECT c.conv_id, c.title, c.description, c.page_url, + json(c.page_meta_jsonb) AS page_meta, c.created_date, c.updated_date, + c.status, c.active_branch_tip_message_id +FROM conversation c +JOIN message m ON m.conv_id = c.conv_id +WHERE json_type(m.content_jsonb, :path) IS NOT NULL; +`; + +export const CONVERSATIONS_CONTENT_SEARCH_BY_ROLE = ` +SELECT c.conv_id, c.title, c.description, c.page_url, + json(c.page_meta_jsonb) AS page_meta, c.created_date, c.updated_date, + c.status, c.active_branch_tip_message_id +FROM conversation c +JOIN message m ON m.conv_id = c.conv_id +WHERE m.role = :role + AND json_type(m.content_jsonb, :path) IS NOT NULL; +`; + +export const CONVERSATIONS_HISTORY_SEARCH = ` +SELECT c.conv_id, c.title, c.description, c.page_url, + json(c.page_meta_jsonb) AS page_meta, c.created_date, c.updated_date, + c.status, c.active_branch_tip_message_id +FROM conversation c +JOIN message m ON m.conv_id = c.conv_id +WHERE m.role = 0 + AND ( + CAST(json_extract(m.content_jsonb, :path) AS TEXT) LIKE :pattern ESCAPE '/' + OR + c.title LIKE :pattern ESCAPE '/' + ); +`; + +export const MESSAGES_BY_DATE = ` +SELECT + message_id, created_date, parent_message_id, revision_root_message_id, + ordinal, is_active_branch, role, model_id, conv_id, + json(params_jsonb) AS params, json(usage_jsonb) AS usage, + page_url, turn_index, insights_enabled, insights_flag_source, + json(insights_applied_jsonb) AS insights_applied, + json(web_search_queries_jsonb) AS web_search_queries, + json(content_jsonb) AS content +FROM message +WHERE created_date >= :start_date AND created_date <= :end_date +ORDER BY created_date DESC +LIMIT :limit OFFSET :offset; +`; + +export const MESSAGES_BY_DATE_AND_ROLE = ` +SELECT + message_id, created_date, parent_message_id, revision_root_message_id, + ordinal, is_active_branch, role, model_id, conv_id, + json(params_jsonb) AS params, json(usage_jsonb) AS usage, + page_url, turn_index, insights_enabled, insights_flag_source, + json(insights_applied_jsonb) AS insights_applied, + json(web_search_queries_jsonb) AS web_search_queries, + json(content_jsonb) AS content +FROM message +WHERE role = :role + AND created_date >= :start_date AND created_date <= :end_date +ORDER BY created_date DESC +LIMIT :limit OFFSET :offset; +`; + +export const DELETE_CONVERSATION_BY_ID = ` +DELETE FROM conversation WHERE conv_id = :conv_id; +`; diff --git a/browser/components/aiwindow/ui/modules/ChatStore.sys.mjs b/browser/components/aiwindow/ui/modules/ChatStore.sys.mjs @@ -0,0 +1,798 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "log", function () { + return console.createInstance({ + prefix: "ChatStore", + maxLogLevelPref: "browser.aiwindow.chatStore.loglevel", + }); +}); + +import { + CONVERSATION_TABLE, + CONVERSATION_UPDATED_DATE_INDEX, + CONVERSATION_INSERT, + MESSAGE_TABLE, + MESSAGE_ORDINAL_INDEX, + MESSAGE_URL_INDEX, + MESSAGE_CREATED_DATE_INDEX, + MESSAGE_INSERT, + CONVERSATIONS_MOST_RECENT, + CONVERSATION_BY_ID, + CONVERSATIONS_BY_DATE, + CONVERSATIONS_BY_URL, + CONVERSATIONS_CONTENT_SEARCH, + CONVERSATIONS_CONTENT_SEARCH_BY_ROLE, + CONVERSATIONS_HISTORY_SEARCH, + MESSAGES_BY_DATE, + MESSAGES_BY_DATE_AND_ROLE, + DELETE_CONVERSATION_BY_ID, + CONVERSATIONS_OLDEST, + ESCAPE_CHAR, + getConversationMessagesSql, +} from "./ChatSql.sys.mjs"; + +import { ChatMinimal } from "./ChatMessage.sys.mjs"; + +export { ChatConversation } from "./ChatConversation.sys.mjs"; +export { ChatMessage, ChatMinimal } from "./ChatMessage.sys.mjs"; +export { + CONVERSATION_STATUS, + MESSAGE_ROLE, + INSIGHTS_FLAG_SOURCE, +} from "./ChatConstants.sys.mjs"; + +import { + CURRENT_SCHEMA_VERSION, + DB_FOLDER_PATH, + DB_FILE_NAME, + PREF_BRANCH, + CONVERSATION_STATUS, +} from "./ChatConstants.sys.mjs"; + +import { + parseConversationRow, + parseMessageRows, + toJSONOrNull, +} from "./ChatUtils.sys.mjs"; + +// NOTE: Reference to migrations file, migrations.mjs has an example +// migration function set up for a migration, and the eslint-disable-next-line +// should be removed once we create the first migration. +// +// eslint-disable-next-line no-unused-vars +import { migrations } from "./ChatMigrations.sys.mjs"; + +const MAX_DB_SIZE_BYTES = 75 * 1024 * 1024; + +/** + * Simple interface to store and retrieve chat conversations and messages. + * + * @todo Bug 2005409 + * Move this documentation to Firefox source docs + * + * See: https://docs.google.com/document/d/1VlwmGbMhPIe-tmeKWinHuPh50VC9QrWEeQQ5V-UvEso/edit?tab=t.klqqibndv3zk + * + * @example + * let { ChatStore, ChatConversation, ChatMessage, MESSAGE_ROLE } = + * ChromeUtils.importESModule("resource:///modules/aiwindow/ui/modules/ChatStore.sys.mjs"); + * const chatStore = new ChatStore(); + * const conversation = new ChatConversation({ + * title: "title", + * description: "description", + * pageUrl: new URL("https://mozilla.com/"), + * pageMeta: { one: 1, two: 2 }, + * }); + * const msg1 = new ChatMessage({ + * ordinal: 0, + * role: MESSAGE_ROLE.USER, + * modelId: "test", + * params: { one: "one" }, + * usage: { two: "two", content: "some content" }, + * }); + * const msg2 = new ChatMessage({ + * ordinal: 1, + * role: MESSAGE_ROLE.ASSISTANT, + * modelId: "test", + * params: { one: "one" }, + * usage: { two: "two", content: "some content 2" }, + * }); + * conversation.messages = [msg1, msg2]; + * await chatStore.updateConversation(conversation); + * // Or findConversationsByDate, findConversationsByURL. + * const foundConversation = + * await chatStore.findConversationById(conversation.id); + * + * @typedef {object} ChatStore + * + * @property {*} x ? + */ +export class ChatStore { + #asyncShutdownBlocker; + #conn; + #promiseConn; + + constructor() { + this.#asyncShutdownBlocker = async () => { + await this.#closeConnection(); + }; + } + + /** + * Updates a conversation's saved state in the SQLite db + * + * @param {ChatConversation} conversation + */ + async updateConversation(conversation) { + await this.#ensureDatabase().catch(e => { + lazy.log.error("Could not ensure a database connection."); + throw e; + }); + + const pageUrl = URL.parse(conversation.pageUrl); + + await this.#conn + .executeTransaction(async () => { + await this.#conn.executeCached(CONVERSATION_INSERT, { + conv_id: conversation.id, + title: conversation.title, + description: conversation.description, + page_url: pageUrl?.href ?? null, + page_meta: toJSONOrNull(conversation.pageMeta), + created_date: conversation.createdDate, + updated_date: conversation.updatedDate, + status: conversation.status, + active_branch_tip_message_id: conversation.activeBranchTipMessageId, + }); + + const messages = conversation.messages.map(m => ({ + message_id: m.id, + conv_id: conversation.id, + created_date: m.createdDate, + parent_message_id: m.parentMessageId, + revision_root_message_id: m.revisionRootMessageId, + ordinal: m.ordinal, + is_active_branch: m.isActiveBranch ? 1 : 0, + role: m.role, + model_id: m.modelId, + params: toJSONOrNull(m.params), + content: toJSONOrNull(m.content), + usage: toJSONOrNull(m.usage), + page_url: m.pageUrl?.href || "", + turn_index: m.turnIndex, + insights_enabled: m.insightsEnabled, + insights_flag_source: m.insightsFlagSource, + insights_applied_jsonb: toJSONOrNull(m.insightsApplied), + web_search_queries_jsonb: toJSONOrNull(m.webSearchQueries), + })); + await this.#conn.executeCached(MESSAGE_INSERT, messages); + }) + .catch(e => { + lazy.log.error("Transaction failed to execute"); + throw e; + }); + } + + /** + * Gets a list of oldest conversations + * + * @param {number} numberOfConversations - How many conversations to retrieve + * @returns {Array<ChatMinimal>} - List of ChatMinimal items + */ + async findOldestConversations(numberOfConversations) { + await this.#ensureDatabase().catch(e => { + lazy.log.error("Could not ensure a database connection."); + throw e; + }); + + const rows = await this.#conn + .executeCached(CONVERSATIONS_OLDEST, { + limit: numberOfConversations, + }) + .catch(e => { + lazy.log.error("Could not retrieve oldest conversations."); + throw e; + }); + + return rows.map(row => { + return new ChatMinimal({ + convId: row.getResultByName("conv_id"), + title: row.getResultByName("title"), + }); + }); + } + + /** + * Gets a list of most recent conversations + * + * @param {number} numberOfConversations - How many conversations to retrieve + * @returns {Array<ChatMinimal>} - List of ChatMinimal items + */ + async findRecentConversations(numberOfConversations) { + await this.#ensureDatabase().catch(e => { + lazy.log.error("Could not ensure a database connection."); + throw e; + }); + + const rows = await this.#conn + .executeCached(CONVERSATIONS_MOST_RECENT, { + limit: numberOfConversations, + }) + .catch(e => { + lazy.log.error("Could not retrieve most recent conversations."); + throw e; + }); + + return rows.map(row => { + return new ChatMinimal({ + convId: row.getResultByName("conv_id"), + title: row.getResultByName("title"), + }); + }); + } + + /** + * Gets a Conversation using it's id + * + * @param {string} conversationId - The ID of the conversation to retrieve + * + * @returns {ChatConversation} - The conversation and its messages + */ + async findConversationById(conversationId) { + const conversations = await this.#findConversationsWithMessages( + CONVERSATION_BY_ID, + { + conv_id: conversationId, + } + ); + + return conversations[0] ?? null; + } + + /** + * Finds conversations between a specified start and end date + * + * @param {number} startDate - Start time epoch format + * @param {number} endDate - End time epoch format + * + * @returns {Array<ChatConversation>} - The conversations and their messages + */ + async findConversationsByDate(startDate, endDate) { + return this.#findConversationsWithMessages(CONVERSATIONS_BY_DATE, { + start_date: startDate, + end_date: endDate, + }); + } + + /** + * Finds conversations between a specified start and end date + * + * @param {URL} pageUrl - The URL to find conversations for + * + * @returns {Array<ChatConversation>} - The conversations and their messages + */ + async findConversationsByURL(pageUrl) { + return this.#findConversationsWithMessages(CONVERSATIONS_BY_URL, { + page_url: pageUrl.href, + }); + } + + /** + * Search for messages that happened between the specified start + * and end dates, optionally, filter the messages by a specific + * message role type. + * + * @param {Date} startDate - The start date, inclusive + * @param {Date} [endDate=new Date()] - The end date, inclusive + * @param {MessageRole} [role=-1] - The message role type to filter by, one of 0|1|2|3 + * as defined by the constant MESSAGE_ROLE + * @param {number} [limit=-1] - The max number of messages to retrieve + * @param {number} [offset=-1] - The number or messages to skip from the result set + * + * @returns {Array<ChatMessage>} - An array of ChatMessage entries + */ + async findMessagesByDate( + startDate, + endDate = new Date(), + role = -1, + limit = -1, + offset = -1 + ) { + const params = { + start_date: startDate.getTime(), + end_date: endDate.getTime(), + limit, + offset, + }; + + let sql = MESSAGES_BY_DATE; + if (role > -1) { + sql = MESSAGES_BY_DATE_AND_ROLE; + params.role = role; + } + + let rows = await this.#conn.executeCached(sql, params); + + return parseMessageRows(rows); + } + + #escapeForLike(searchString) { + return searchString + .replaceAll(ESCAPE_CHAR, `${ESCAPE_CHAR}${ESCAPE_CHAR}`) + .replaceAll("%", `${ESCAPE_CHAR}%`) + .replaceAll("_", `${ESCAPE_CHAR}_`); + } + + /** + * Searches through the message.content JSON object to find a particular + * object path that contains a partial string match of a value. + * + * @param {string} keyChain - The object key chain to look through, + * like obj.field1.field2 + * @param {MessageRole} [role=-1] - A message role to search for + * + * @returns {Array<ChatConversation>} - An array of conversations with messages + * that contain a message that matches the search string at the given content + * object path + */ + async searchContent(keyChain, role = -1) { + const path = `$.${keyChain}`; + + const query = + role > -1 + ? CONVERSATIONS_CONTENT_SEARCH_BY_ROLE + : CONVERSATIONS_CONTENT_SEARCH; + + const params = { path }; + + if (role > -1) { + params.role = role; + } + + const rows = await this.#conn.executeCached(query, params); + + if (!rows.length) { + return []; + } + + const conversations = rows.map(parseConversationRow); + + return await this.#getMessagesForConversations(conversations); + } + + /** + * Searches for conversations where the conversation title, or the conversation + * contains a user message where the search string contains a partial match + * in the message.content.body field + * + * @param {string} searchString - The string to search with for conversations + * + * @returns {Array<ChatConversation>} - An array of conversations with messages + * that contain a message that matches the search string in the conversation + * titles + */ + async search(searchString) { + const path = `$.body`; + const pattern = `%${this.#escapeForLike(searchString)}%`; + + const rows = await this.#conn.executeCached(CONVERSATIONS_HISTORY_SEARCH, { + path, + pattern, + }); + + if (!rows.length) { + return []; + } + + const conversations = rows.map(parseConversationRow); + + return await this.#getMessagesForConversations(conversations); + } + + /** + * Prunes the database of old conversations in order to get the + * database file size to the specified maximum size. + * + * @todo Bug 2005411 + * Review the requirements for db pruning and set up invocation schedule, and refactor + * to use dbstat + * + * @param {number} [reduceByPercentage=0.05] - Percentage to reduce db file size by + * @param {number} [maxDbSizeBytes=MAX_DB_SIZE_BYTES] - Db max file size + */ + async pruneDatabase( + reduceByPercentage = 0.05, + maxDbSizeBytes = MAX_DB_SIZE_BYTES + ) { + if (!IOUtils.exists(this.databaseFilePath)) { + return; + } + + const DELETE_BATCH_SIZE = 50; + + const getPragmaInt = async name => { + const result = await this.#conn.execute(`PRAGMA ${name}`); + return result[0].getInt32(0); + }; + + // compute the logical DB size in bytes using SQLite's page_size, + // page_count, and freelist_count + const getLogicalDbSizeBytes = async () => { + const pageSize = await getPragmaInt("page_size"); + const pageCount = await getPragmaInt("page_count"); + const freelistCount = await getPragmaInt("freelist_count"); + + // Logical used pages = total pages - free pages + const usedPages = pageCount - freelistCount; + const lSize = usedPages * pageSize; + + return lSize; + }; + + let logicalSize = await getLogicalDbSizeBytes(); + if (logicalSize < maxDbSizeBytes) { + return; + } + + const targetLogicalSize = Math.max( + 0, + logicalSize * (1 - reduceByPercentage) + ); + + const MAX_ITERATIONS = 100; + // how many "no file size change" batches we tolerate + const MAX_STAGNANT = 5; + let iterations = 0; + let stagnantIterations = 0; + + while ( + logicalSize > targetLogicalSize && + iterations < MAX_ITERATIONS && + stagnantIterations < MAX_STAGNANT + ) { + iterations++; + + const recentChats = await this.findOldestConversations(DELETE_BATCH_SIZE); + + if (!recentChats.length) { + break; + } + + for (const chat of recentChats) { + await this.deleteConversationById(chat.id); + } + + const newLogicalSize = await getLogicalDbSizeBytes(); + if (newLogicalSize >= logicalSize) { + stagnantIterations++; + } else { + stagnantIterations = 0; + } + + logicalSize = newLogicalSize; + } + + // Actually reclaim disk space. + await this.#conn.execute("PRAGMA incremental_vacuum;"); + } + + /** + * Returns the file size of the database. + * Establishes a connection first to make sure the + * database exists. + * + * @returns {number} - The file size in bytes + */ + async getDatabaseSize() { + await this.#ensureDatabase(); + + const stats = await IOUtils.stat(this.databaseFilePath); + return stats.size; + } + + /** + * Deletes a particular conversation using it's id + * + * @param {string} id - The conv_id of a conversation row to delete + */ + async deleteConversationById(id) { + await this.#ensureDatabase(); + + await this.#conn.execute(DELETE_CONVERSATION_BY_ID, { + conv_id: id, + }); + } + + /** + * This method is meant to only be used for testing cleanup + */ + async destroyDatabase() { + await this.#removeDatabaseFiles(); + } + + /** + * Gets the version of the schema currently set in the database. + * + * @returns {number} + */ + async getDatabaseSchemaVersion() { + if (!this.#conn) { + await this.#ensureDatabase(); + } + + return this.#conn.getSchemaVersion(); + } + + async #getMessagesForConversations(conversations) { + const convs = conversations.reduce((convMap, conv) => { + convMap[conv.id] = conv; + + return convMap; + }, {}); + + // Find all the messages for all the conversations. + const rows = await this.#conn + .executeCached( + getConversationMessagesSql(conversations.length), + conversations.map(c => c.id) + ) + .catch(e => { + lazy.log.error("Could not retrieve messages for conversatons"); + lazy.log.error(`${e.message}\n${e.stack}`); + + return []; + }); + + // TODO: retrieve TTL content. + + parseMessageRows(rows).forEach(message => { + const conversation = convs[message.convId]; + if (conversation) { + conversation.messages.push(message); + } + }); + + return conversations; + } + + async #openConnection() { + lazy.log.debug("Opening new connection"); + + try { + const confConfig = { path: this.databaseFilePath }; + this.#conn = await lazy.Sqlite.openConnection(confConfig); + } catch (e) { + lazy.log.error("openConnection() could not open db:", e.message); + throw e; + } + + lazy.Sqlite.shutdown.addBlocker( + "ChatStore: Shutdown", + this.#asyncShutdownBlocker + ); + + try { + // TODO: remove this after switching pruneDatabase() to use dbstat + await this.#conn.execute("PRAGMA page_size = 4096;"); + // Setup WAL journaling, as it is generally faster. + await this.#conn.execute("PRAGMA journal_mode = WAL;"); + await this.#conn.execute("PRAGMA wal_autocheckpoint = 16;"); + + // Store VACUUM information to be used by the VacuumManager. + await this.#conn.execute("PRAGMA auto_vacuum = INCREMENTAL;"); + await this.#conn.execute("PRAGMA foreign_keys = ON;"); + } catch (e) { + lazy.log.warn("Configuring SQLite PRAGMA settings: ", e.message); + } + } + + async #closeConnection() { + if (!this.#conn) { + return; + } + + lazy.log.debug("Closing connection"); + lazy.Sqlite.shutdown.removeBlocker(this.#asyncShutdownBlocker); + try { + await this.#conn.close(); + } catch (e) { + lazy.log.warn(`Error closing connection: ${e.message}`); + } + this.#conn = null; + } + + /** + * @todo Bug 2005412 + * Discuss implications of multiple instances of ChatStore + * and the potential issues with migrations/schemas. + */ + async #ensureDatabase() { + if (this.#promiseConn) { + return this.#promiseConn; + } + + let deferred = Promise.withResolvers(); + this.#promiseConn = deferred.promise; + if (this.#removeDatabaseOnStartup) { + lazy.log.debug("Removing database on startup"); + try { + await this.#removeDatabaseFiles(); + } catch (e) { + deferred.reject(new Error("Could not remove the database files")); + return deferred.promise; + } + } + + try { + await this.#openConnection(); + } catch (e) { + if ( + e.result == Cr.NS_ERROR_FILE_CORRUPTED || + e.errors?.some(error => error.result == Ci.mozIStorageError.NOTADB) + ) { + lazy.log.warn("Invalid database detected, removing it.", e); + await this.#removeDatabaseFiles(); + } + } + + if (!this.#conn) { + try { + await this.#openConnection(); + } catch (e) { + lazy.log.error("Could not open the database connection.", e); + deferred.reject(new Error("Could not open the database connection")); + return deferred.promise; + } + } + + try { + await this.#initializeSchema(); + } catch (e) { + lazy.log.warn( + "Failed to initialize the database schema, recreating the database.", + e + ); + // If the schema cannot be initialized try to create a new database file. + await this.#removeDatabaseFiles(); + } + + deferred.resolve(this.#conn); + return this.#promiseConn; + } + + async setSchemaVersion(version) { + await this.#conn.setSchemaVersion(version); + } + + async #initializeSchema() { + const version = await this.getDatabaseSchemaVersion(); + + if (version == this.CURRENT_SCHEMA_VERSION) { + return; + } + + if (version > this.CURRENT_SCHEMA_VERSION) { + await this.setSchemaVersion(this.CURRENT_SCHEMA_VERSION); + return; + } + + // Must migrate the schema. + await this.#conn.executeTransaction(async () => { + if (version == 0) { + // This is a newly created database, just create the entities. + await this.#createDatabaseEntities(); + await this.#conn.setSchemaVersion(this.CURRENT_SCHEMA_VERSION); + // eslint-disable-next-line no-useless-return + return; + } + + await this.applyMigrations(); + await this.setSchemaVersion(this.CURRENT_SCHEMA_VERSION); + }); + } + + async applyMigrations() { + for (const migration of migrations) { + if (typeof migration !== "function") { + continue; + } + + await migration(this.#conn, this.CURRENT_SCHEMA_VERSION); + } + } + + async #removeDatabaseFiles() { + lazy.log.debug("Removing database files"); + await this.#closeConnection(); + try { + for (let file of [ + this.databaseFilePath, + PathUtils.join(DB_FOLDER_PATH, this.databaseFileName + "-wal"), + PathUtils.join(DB_FOLDER_PATH, this.databaseFileName + "-shm"), + ]) { + lazy.log.debug(`Removing ${file}`); + await IOUtils.remove(file, { + retryReadonly: true, + recursive: true, + ignoreAbsent: true, + }); + } + this.#removeDatabaseOnStartup = false; + } catch (e) { + lazy.log.warn("Failed to remove database files", e); + // Try to clear on next startup. + this.#removeDatabaseOnStartup = true; + // Re-throw the exception for the caller. + throw e; + } + } + + async #findConversationsWithMessages(sql, queryParams) { + await this.#ensureDatabase().catch(e => { + lazy.log.error("Could not ensure a database connection."); + lazy.log.error(`${e.message}\n${e.stack}`); + + return []; + }); + + // @todo Bug 2005414 + // Check summary first, find the one with the largest end_ordinal. + // If not found retrieve all messages. + // If found compare end_ordinal of the summary with active branch ordinal + // to determine if extra messages must be retrieved. + let rows = await this.#conn.executeCached(sql, queryParams); + + const conversations = rows.map(parseConversationRow); + + return await this.#getMessagesForConversations(conversations); + } + + async #createDatabaseEntities() { + await this.#conn.execute(CONVERSATION_TABLE); + await this.#conn.execute(CONVERSATION_UPDATED_DATE_INDEX); + await this.#conn.execute(MESSAGE_TABLE); + await this.#conn.execute(MESSAGE_ORDINAL_INDEX); + await this.#conn.execute(MESSAGE_URL_INDEX); + await this.#conn.execute(MESSAGE_CREATED_DATE_INDEX); + } + + get #removeDatabaseOnStartup() { + return Services.prefs.getBoolPref( + `${PREF_BRANCH}.removeDatabaseOnStartup`, + false + ); + } + + set #removeDatabaseOnStartup(value) { + lazy.log.debug(`Setting removeDatabaseOnStartup to ${value}`); + Services.prefs.setBoolPref(`${PREF_BRANCH}.removeDatabaseOnStartup`, value); + } + + static get CONVERSATION_STATUS() { + return CONVERSATION_STATUS; + } + + get CURRENT_SCHEMA_VERSION() { + return CURRENT_SCHEMA_VERSION; + } + + get connection() { + return this.#conn; + } + + get databaseFileName() { + return DB_FILE_NAME; + } + + get databaseFilePath() { + return PathUtils.join(PathUtils.profileDir, this.databaseFileName); + } +} diff --git a/browser/components/aiwindow/ui/modules/ChatUtils.sys.mjs b/browser/components/aiwindow/ui/modules/ChatUtils.sys.mjs @@ -0,0 +1,132 @@ +/* + 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 https://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CryptoUtils: "moz-src:///services/crypto/modules/utils.sys.mjs", +}); + +import { MESSAGE_ROLE } from "./ChatConstants.sys.mjs"; +import { ChatConversation } from "./ChatConversation.sys.mjs"; +import { ChatMessage } from "./ChatMessage.sys.mjs"; + +/** + * Creates a 12 characters GUID with 72 bits of entropy. + * + * @returns {string} A base64url encoded GUID. + */ +export function makeGuid() { + return ChromeUtils.base64URLEncode(lazy.CryptoUtils.generateRandomBytes(9), { + pad: false, + }); +} + +/** + * Parse a conversation row from the database into a ChatConversation + * object. + * + * @param {object} row - The database row to parse. + * @returns {ChatConversation} The parsed conversation object. + */ +export function parseConversationRow(row) { + return new ChatConversation({ + id: row.getResultByName("conv_id"), + title: row.getResultByName("title"), + description: row.getResultByName("description"), + pageUrl: URL.parse(row.getResultByName("page_url")), + pageMeta: parseJSONOrNull(row.getResultByName("page_meta")), + createdDate: row.getResultByName("created_date"), + updatedDate: row.getResultByName("updated_date"), + status: row.getResultByName("status"), + }); +} + +/** + * Parse message rows from the database into an array of ChatMessage + * objects. + * + * @param {object} rows - The database rows to parse. + * @returns {Array<ChatMessage>} The parsed message objects. + */ +export function parseMessageRows(rows) { + return rows.map(row => { + return new ChatMessage({ + id: row.getResultByName("message_id"), + createdDate: row.getResultByName("created_date"), + parentMessageId: row.getResultByName("parent_message_id"), + revisionRootMessageId: row.getResultByName("revision_root_message_id"), + ordinal: row.getResultByName("ordinal"), + isActiveBranch: !!row.getResultByName("is_active_branch"), + role: row.getResultByName("role"), + modelId: row.getResultByName("model_id"), + params: parseJSONOrNull(row.getResultByName("params")), + usage: parseJSONOrNull(row.getResultByName("usage")), + content: parseJSONOrNull(row.getResultByName("content")), + convId: row.getResultByName("conv_id"), + pageUrl: URL.parse(row.getResultByName("page_url")), + turnIndex: row.getResultByName("turn_index"), + insightsEnabled: row.getResultByName("insights_enabled"), + insightsFlagSource: row.getResultByName("insights_flag_source"), + insightsApplied: parseJSONOrNull(row.getResultByName("insights_applied")), + webSearchQueries: parseJSONOrNull( + row.getResultByName("web_search_queries") + ), + }); + }); +} + +/** + * Try to parse a JSON string, returning null if it fails or the value is falsy. + * + * @param {string} value - The JSON string to parse. + * @returns {object|null} The parsed object or null. + */ +export function parseJSONOrNull(value) { + if (!value) { + return null; + } + try { + return JSON.parse(value); + } catch (e) { + return null; + } +} + +/** + * Try to stringify a value if it is truthy, otherwise return null. + * + * @param {*} value - A value to JSON.stringify() + * + * @returns {string|null} - JSON string + */ +export function toJSONOrNull(value) { + return value ? JSON.stringify(value) : null; +} + +/** + * Converts the different types of message roles from + * the database numeric type to a string label + * + * @param {number} role - The database numeric role type + * @returns {string} - A human readable role label + */ +export function getRoleLabel(role) { + switch (role) { + case MESSAGE_ROLE.USER: + return "User"; + + case MESSAGE_ROLE.ASSISTANT: + return "Assistant"; + + case MESSAGE_ROLE.SYSTEM: + return "System"; + + case MESSAGE_ROLE.TOOL: + return "Tool"; + } + + return ""; +} diff --git a/browser/components/aiwindow/ui/moz.build b/browser/components/aiwindow/ui/moz.build @@ -11,6 +11,15 @@ MOZ_SRC_FILES += [ "actors/AIChatContentChild.sys.mjs", "actors/AIChatContentParent.sys.mjs", "modules/AIWindow.sys.mjs", + "modules/ChatConstants.sys.mjs", + "modules/ChatConversation.sys.mjs", + "modules/ChatMessage.sys.mjs", + "modules/ChatMigrations.sys.mjs", + "modules/ChatSql.sys.mjs", + "modules/ChatStore.sys.mjs", + "modules/ChatUtils.sys.mjs", ] JAR_MANIFESTS += ["jar.mn"] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] diff --git a/browser/components/aiwindow/ui/test/xpcshell/asserts.js b/browser/components/aiwindow/ui/test/xpcshell/asserts.js @@ -0,0 +1,256 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * @todo Bug 2005403 + * Move this to main assert module, could look like: + * await Assert.continueOnAssert(async function() { + * Assert.ok(); + * await Assert.rejects(); + * ... + * }); + */ +function SoftAssert() { + this.failures = []; +} + +SoftAssert.prototype._recordFailure = function (err, context) { + this.failures.push({ + message: err.message, + name: err.name, + stack: err.stack, + context, + }); +}; + +SoftAssert.prototype.throws = function ( + block, + expectedError, + message = "throws failed" +) { + try { + Assert.throws(block, expectedError, message); + + this._recordFailure(new Error("Did not throw"), { + type: "throws", + expectedError, + message, + }); + } catch {} +}; + +SoftAssert.prototype.ok = function (condition, message = "ok failed") { + try { + Assert.ok(condition, message); + } catch (err) { + this._recordFailure(err, { type: "ok", condition, message }); + } +}; + +SoftAssert.prototype.equal = function ( + actual, + expected, + message = "equal failed" +) { + try { + Assert.equal(actual, expected, message); + } catch (err) { + this._recordFailure(err, { type: "equal", actual, expected, message }); + } +}; + +SoftAssert.prototype.notEqual = function ( + actual, + expected, + message = "notEqual failed" +) { + try { + Assert.notEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { type: "notEqual", actual, expected, message }); + } +}; + +SoftAssert.prototype.deepEqual = function ( + actual, + expected, + message = "deepEqual failed" +) { + try { + Assert.deepEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { type: "deepEqual", actual, expected, message }); + } +}; + +SoftAssert.prototype.notDeepEqual = function ( + actual, + expected, + message = "notDeepEqual failed" +) { + try { + Assert.notDeepEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { + type: "notDeepEqual", + actual, + expected, + message, + }); + } +}; + +SoftAssert.prototype.strictEqual = function ( + actual, + expected, + message = "strictEqual failed" +) { + try { + Assert.strictEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { + type: "strictEqual", + actual, + expected, + message, + }); + } +}; + +SoftAssert.prototype.notStrictEqual = function ( + actual, + expected, + message = "notStrictEqual failed" +) { + try { + Assert.notStrictEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { + type: "notStrictEqual", + actual, + expected, + message, + }); + } +}; + +SoftAssert.prototype.rejects = async function ( + actual, + expected, + message = "rejects failed" +) { + try { + await Assert.rejects(actual, expected, message); + } catch (err) { + this._recordFailure(err, { type: "rejects", actual, expected, message }); + } +}; + +SoftAssert.prototype.greater = function ( + actual, + expected, + message = "greater failed" +) { + try { + Assert.greater(actual, expected, message); + } catch (err) { + this._recordFailure(err, { type: "greater", actual, expected, message }); + } +}; + +SoftAssert.prototype.greaterOrEqual = function ( + actual, + expected, + message = "greaterOrEqual failed" +) { + try { + Assert.greaterOrEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { + type: "greaterOrEqual", + actual, + expected, + message, + }); + } +}; + +SoftAssert.prototype.less = function ( + actual, + expected, + message = "less failed" +) { + try { + Assert.less(actual, expected, message); + } catch (err) { + this._recordFailure(err, { type: "less", actual, expected, message }); + } +}; + +SoftAssert.prototype.lessOrEqual = function ( + actual, + expected, + message = "lessOrEqual failed" +) { + try { + Assert.lessOrEqual(actual, expected, message); + } catch (err) { + this._recordFailure(err, { + type: "lessOrEqual", + actual, + expected, + message, + }); + } +}; + +// Call this at the end to fail the test if any soft asserts failed. +SoftAssert.prototype.assertAll = function (label = "Soft assertion failures") { + if (!this.failures.length) { + return; + } + + let details = `${label} (${this.failures.length}):\n`; + for (let i = 0; i < this.failures.length; i++) { + const f = this.failures[i]; + details += `\n[${i + 1}] ${f.message}\n`; + if (f.context) { + details += ` Context: ${JSON.stringify(f.context)}\n`; + } + if (f.stack) { + details += ` Stack: ${f.stack}\n`; + } + } + + // One combined failure at the end: + throw new Error(details); +}; + +function softAssertions(Assert) { + /** + * Provides a way to execute multiple assertions without exiting + * a test after the first assertion failure. All assertions in the + * provided callback will run even if some assertions fail. If an + * assertion fails, the test will fail and provide details on the + * failed assertion(s). + * + * @example + * Assert.withSoftAssertions(function(soft) { + * // Use usual Assert functions via `soft` reference: + * soft.equal(conversation.id.length, 12); + * soft.ok(Array.isArray(conversation.messages)); + * soft.ok(!isNaN(conversation.createdDate)); + * }); + * + * @param {Function} tests - A callback that acceps a `soft` object to run assertions with. + */ + Assert.withSoftAssertions = function (tests) { + const soft = new SoftAssert(); + tests(soft); + soft.assertAll(); + }; +} + +softAssertions(Assert); diff --git a/browser/components/aiwindow/ui/test/xpcshell/test_ChatConversation.js b/browser/components/aiwindow/ui/test/xpcshell/test_ChatConversation.js @@ -0,0 +1,412 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +do_get_profile(); + +const { ChatConversation, MESSAGE_ROLE } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatStore.sys.mjs" +); + +const { UserRoleOpts, AssistantRoleOpts, ToolRoleOpts } = + ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatMessage.sys.mjs" + ); + +add_task(function test_ChatConversation_constructor_defaults() { + const conversation = new ChatConversation({}); + + Assert.withSoftAssertions(function (soft) { + soft.equal(conversation.id.length, 12); + soft.ok(Array.isArray(conversation.messages)); + soft.ok(!isNaN(conversation.createdDate)); + soft.ok(!isNaN(conversation.updatedDate)); + soft.strictEqual(conversation.title, undefined); + soft.strictEqual(conversation.description, undefined); + soft.strictEqual(conversation.pageUrl, undefined); + soft.strictEqual(conversation.pageMeta, undefined); + }); +}); + +add_task(function test_ChatConversation_addMessage() { + const conversation = new ChatConversation({}); + + const content = { + type: "text", + content: "hello world", + }; + + conversation.addMessage( + MESSAGE_ROLE.USER, + content, + new URL("https://www.mozilla.com"), + 0 + ); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.strictEqual(message.role, MESSAGE_ROLE.USER); + soft.strictEqual(message.content, content); + soft.strictEqual(message.pageUrl.href, "https://www.mozilla.com/"); + soft.strictEqual(message.turnIndex, 0); + }); +}); + +add_task(function test_invalidRole_ChatConversation_addMessage() { + const conversation = new ChatConversation({}); + + const content = { + type: "text", + content: "hello world", + }; + + conversation.addMessage(313, content, new URL("https://www.mozilla.com"), 0); + + Assert.equal(conversation.messages.length, 0); +}); + +add_task(function test_negativeTurnIndex_ChatConversation_addMessage() { + const conversation = new ChatConversation({}); + + const content = { + type: "text", + content: "hello world", + }; + + conversation.addMessage( + MESSAGE_ROLE.USER, + content, + new URL("https://www.mozilla.com"), + -1 + ); + const message = conversation.messages[0]; + + Assert.equal(message.turnIndex, 0); +}); + +add_task(function test_parentMessageId_ChatConversation_addMessage() { + const conversation = new ChatConversation({}); + + const content = { + type: "text", + content: "hello world", + }; + + conversation.addMessage( + MESSAGE_ROLE.USER, + content, + new URL("https://www.mozilla.com"), + 0 + ); + + conversation.addMessage( + MESSAGE_ROLE.ASSISTANT, + content, + new URL("https://www.mozilla.com"), + 0 + ); + + const userMsg = conversation.messages[0]; + const assistantMsg = conversation.messages[1]; + + Assert.equal(assistantMsg.parentMessageId, userMsg.id); +}); + +add_task(function test_ordinal_ChatConversation_addMessage() { + const conversation = new ChatConversation({}); + + const content = { + type: "text", + content: "hello world", + }; + + conversation.addMessage( + MESSAGE_ROLE.USER, + content, + new URL("https://www.mozilla.com"), + 0 + ); + + conversation.addMessage( + MESSAGE_ROLE.ASSISTANT, + content, + new URL("https://www.mozilla.com"), + 0 + ); + + const userMsg = conversation.messages[0]; + const assistantMsg = conversation.messages[1]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(userMsg.ordinal, 1); + soft.equal(assistantMsg.ordinal, 2); + }); +}); + +add_task(function test_ChatConversation_addUserMessage() { + const conversation = new ChatConversation({}); + + const content = "user to assistant msg"; + conversation.addUserMessage(content, "https://www.mozilla.com", 0); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.role, MESSAGE_ROLE.USER); + soft.equal(message.turnIndex, 0); + soft.deepEqual(message.pageUrl, new URL("https://www.mozilla.com")); + soft.deepEqual(message.content, { + type: "text", + body: "user to assistant msg", + }); + }); +}); + +add_task(function test_revisionRootMessageId_ChatConversation_addUserMessage() { + const conversation = new ChatConversation({}); + + const content = "user to assistant msg"; + conversation.addUserMessage(content, "https://www.firefox.com", 0); + + const message = conversation.messages[0]; + + Assert.equal(message.revisionRootMessageId, message.id); +}); + +add_task(function test_opts_ChatConversation_addUserMessage() { + const conversation = new ChatConversation({}); + + const content = "user to assistant msg"; + conversation.addUserMessage( + content, + "https://www.firefox.com", + 0, + new UserRoleOpts("321") + ); + + const message = conversation.messages[0]; + + Assert.equal(message.revisionRootMessageId, "321"); +}); + +add_task(function test_ChatConversation_addAssistantMessage() { + const conversation = new ChatConversation({}); + + const content = "response from assistant"; + conversation.addAssistantMessage("text", content, 0); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.role, MESSAGE_ROLE.ASSISTANT); + soft.equal(message.turnIndex, 0); + soft.deepEqual(message.pageUrl, null); + soft.deepEqual(message.content, { + type: "text", + body: "response from assistant", + }); + soft.strictEqual(message.modelId, null, "modelId should default to false"); + soft.strictEqual(message.params, null, "params should default to null"); + soft.strictEqual(message.usage, null, "usage should default to null"); + soft.strictEqual( + message.insightsEnabled, + false, + "insightsEnabled should default to false" + ); + soft.strictEqual( + message.insightsFlagSource, + null, + "insightsFlagSource should default to null" + ); + soft.deepEqual( + message.insightsApplied, + [], + "insightsApplied should default to emtpy array" + ); + soft.deepEqual( + message.webSearchQueries, + [], + "webSearchQueries should default to emtpy array" + ); + }); +}); + +add_task(function test_opts_ChatConversation_addAssistantMessage() { + const conversation = new ChatConversation({}); + + const content = "response from assistant"; + const assistantOpts = new AssistantRoleOpts( + "the-model-id", + { some: "params for model" }, + { usage: "data" }, + true, + 1, + ["insight"], + ["search"] + ); + conversation.addAssistantMessage("text", content, 0, assistantOpts); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.role, MESSAGE_ROLE.ASSISTANT); + soft.equal(message.turnIndex, 0); + soft.deepEqual(message.pageUrl, null); + soft.deepEqual(message.content, { + type: "text", + body: "response from assistant", + }); + soft.strictEqual( + message.modelId, + "the-model-id", + "modelId should be 'the-model-id'" + ); + soft.deepEqual( + message.params, + { some: "params for model" }, + 'params should equal { some: "params for model"}' + ); + soft.deepEqual( + message.usage, + { usage: "data" }, + 'usage should equal {"usage": "data"}' + ); + soft.strictEqual( + message.insightsEnabled, + true, + "insightsEnabled should equal true" + ); + soft.strictEqual( + message.insightsFlagSource, + 1, + "insightsFlagSource equal 1" + ); + soft.deepEqual( + message.insightsApplied, + ["insight"], + "insightsApplied should equal ['insight']" + ); + soft.deepEqual( + message.webSearchQueries, + ["search"], + "insightsApplied should equal ['search']" + ); + }); +}); + +add_task(function test_ChatConversation_addToolCallMessage() { + const conversation = new ChatConversation({}); + + const content = { + random: "tool call specific keys", + }; + conversation.addToolCallMessage(content, 0); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.role, MESSAGE_ROLE.TOOL); + soft.equal(message.turnIndex, 0); + soft.deepEqual(message.pageUrl, null); + soft.deepEqual(message.content, { + random: "tool call specific keys", + }); + soft.equal(message.modelId, null, "modelId should default to null"); + }); +}); + +add_task(function test_opts_ChatConversation_addToolCallMessage() { + const conversation = new ChatConversation({}); + + const content = { + random: "tool call specific keys", + }; + conversation.addToolCallMessage(content, 0, new ToolRoleOpts("the-model-id")); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.role, MESSAGE_ROLE.TOOL); + soft.equal(message.turnIndex, 0); + soft.deepEqual(message.pageUrl, null); + soft.deepEqual(message.content, { + random: "tool call specific keys", + }); + soft.equal( + message.modelId, + "the-model-id", + "modelId should equal the-model-id" + ); + }); +}); + +add_task(function test_ChatConversation_addSystemMessage() { + const conversation = new ChatConversation({}); + + const content = { + random: "system call specific keys", + }; + conversation.addSystemMessage("text", content, 0); + + const message = conversation.messages[0]; + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.role, MESSAGE_ROLE.SYSTEM); + soft.equal(message.turnIndex, 0); + soft.deepEqual(message.pageUrl, null); + soft.deepEqual(message.content, { + type: "text", + body: { random: "system call specific keys" }, + }); + }); +}); + +add_task(function test_ChatConversation_getSitesList() { + const conversation = new ChatConversation({}); + + const content = "user to assistant msg"; + conversation.addUserMessage(content, "https://www.mozilla.com", 0); + conversation.addUserMessage(content, "https://www.mozilla.com", 1); + conversation.addUserMessage(content, "https://www.firefox.com", 2); + conversation.addUserMessage(content, "https://www.cnn.com", 3); + conversation.addUserMessage(content, "https://www.espn.com", 4); + conversation.addUserMessage(content, "https://www.espn.com", 5); + + const sites = conversation.getSitesList(); + + Assert.deepEqual(sites, [ + URL.parse("https://www.mozilla.com/"), + URL.parse("https://www.firefox.com/"), + URL.parse("https://www.cnn.com/"), + URL.parse("https://www.espn.com/"), + ]); +}); + +add_task(function test_ChatConversation_getMostRecentPageVisited() { + const conversation = new ChatConversation({}); + + const content = "user to assistant msg"; + conversation.addUserMessage(content, "https://www.mozilla.com", 0); + conversation.addUserMessage(content, "https://www.mozilla.com", 1); + conversation.addUserMessage(content, "https://www.firefox.com", 2); + conversation.addUserMessage(content, "https://www.cnn.com", 3); + conversation.addUserMessage(content, "https://www.espn.com", 4); + conversation.addUserMessage(content, "https://www.espn.com", 5); + + const mostRecentPageVisited = conversation.getMostRecentPageVisited(); + + Assert.equal(mostRecentPageVisited, "https://www.espn.com/"); +}); + +add_task(function test_noBrowsing_ChatConversation_getMostRecentPageVisited() { + const conversation = new ChatConversation({}); + + const content = "user to assistant msg"; + conversation.addUserMessage(content, "about:aiwindow", 0); + conversation.addUserMessage(content, "", 1); + conversation.addUserMessage(content, null, 2); + + const mostRecentPageVisited = conversation.getMostRecentPageVisited(); + + Assert.equal(mostRecentPageVisited, null); +}); diff --git a/browser/components/aiwindow/ui/test/xpcshell/test_ChatMessage.js b/browser/components/aiwindow/ui/test/xpcshell/test_ChatMessage.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +do_get_profile(); + +const { ChatMessage } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatStore.sys.mjs" +); + +add_task(function test_ChatConversation_constructor_defaults() { + const message = new ChatMessage({ + ordinal: 0, + role: 0, + turnIndex: 0, + content: "some content", + pageUrl: new URL("https://www.mozilla.com"), + }); + + Assert.withSoftAssertions(function (soft) { + soft.equal(message.id.length, 12); + soft.equal(message.revisionRootMessageId, message.id); + soft.ok(!isNaN(message.createdDate)); + soft.ok(message.isActiveBranch); + const nullFields = [ + "parentMessageId", + "modelId", + "params", + "usage", + "convId", + "insightsEnabled", + "insightsFlagSource", + "insightsApplied", + "webSearchQueries", + ]; + + nullFields.forEach(nullField => { + soft.equal( + message[nullField], + null, + `message.${nullField} should default to null` + ); + }); + }); +}); + +add_task(function test_pageUrl_as_URL_ChatConversation() { + const message = new ChatMessage({ + ordinal: 0, + role: 0, + turnIndex: 0, + content: "some content", + pageUrl: new URL("https://www.mozilla.com"), + }); + + Assert.withSoftAssertions(function (soft) { + soft.ok(URL.isInstance(message.pageUrl)); + soft.equal(message.pageUrl.href, "https://www.mozilla.com/"); + }); +}); + +add_task(function test_pageUrl_as_string_ChatConversation() { + const message = new ChatMessage({ + ordinal: 0, + role: 0, + turnIndex: 0, + content: "some content", + pageUrl: "https://www.mozilla.com", + }); + + Assert.withSoftAssertions(function (soft) { + soft.ok(URL.isInstance(message.pageUrl)); + soft.equal(message.pageUrl.href, "https://www.mozilla.com/"); + }); +}); + +add_task(function test_invalid_pageUrl_ChatConversation() { + Assert.throws( + function () { + new ChatMessage({ + ordinal: 0, + role: 0, + turnIndex: 0, + content: "some content", + pageUrl: "www.mozilla.com", + }); + }, + new RegExp("URL constructor: www.mozilla.com is not a valid URL"), + "Did not get correct error message" + ); +}); + +add_task(function test_missing_pageUrl_ChatConversation() { + const message = new ChatMessage({ + ordinal: 0, + role: 0, + turnIndex: 0, + content: "some content", + }); + + Assert.equal(message.pageUrl, null); +}); + +add_task(function test_empty_pageUrl_ChatConversation() { + const message = new ChatMessage({ + ordinal: 0, + role: 0, + turnIndex: 0, + content: "some content", + pageUrl: "", + }); + + Assert.equal(message.pageUrl, null); +}); diff --git a/browser/components/aiwindow/ui/test/xpcshell/test_ChatStore.js b/browser/components/aiwindow/ui/test/xpcshell/test_ChatStore.js @@ -0,0 +1,613 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +do_get_profile(); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +const { ChatStore, ChatConversation, ChatMessage } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatStore.sys.mjs" +); + +async function addBasicConvoTestData(date, title, updated = null) { + const link = "https://www.firefox.com"; + const updatedDate = updated || date; + + return addConvoWithSpecificTestData( + new Date(date), + link, + link, + title, + "test content", + new Date(updatedDate) + ); +} + +async function addBasicConvoWithSpecificUpdatedTestData(updatedDate, title) { + const link = "https://www.firefox.com"; + return addConvoWithSpecificTestData( + new Date("1/1/2023"), + link, + link, + title, + "test content", + new Date(updatedDate) + ); +} + +async function addConvoWithSpecificTestData( + createdDate, + mainLink, + messageLink, + title, + message = "the message body", + updatedDate = false +) { + const conversation = new ChatConversation({ + createdDate: createdDate.getTime(), + updatedDate: updatedDate ? updatedDate.getTime() : createdDate.getTime(), + pageUrl: mainLink, + }); + conversation.title = title; + conversation.addUserMessage(message, messageLink, 0); + await gChatStore.updateConversation(conversation); +} + +async function addConvoWithSpecificCustomContentTestData( + createdDate, + mainLink, + messageLink, + title, + content, + role +) { + const conversation = new ChatConversation({ + createdDate: createdDate.getTime(), + updatedDate: createdDate.getTime(), + pageUrl: mainLink, + }); + conversation.title = title; + conversation.addMessage(role, content, messageLink, 0); + await gChatStore.updateConversation(conversation); +} + +/** + * Runs a test atomically so that the clean up code + * runs after each test intead of after the entire + * list of tasks in the file are done. + * + * @todo Bug 2005408 + * Replace add_atomic_task usage when this Bug 1656557 lands + * + * @param {Function} func - The test function to run + */ +function add_atomic_task(func) { + return add_task(async function () { + await test_ChatStorage_setup(); + + try { + await func(); + } finally { + await test_cleanUp(); + } + }); +} + +let gChatStore, gSandbox; + +async function cleanUpDatabase() { + if (gChatStore) { + await gChatStore.destroyDatabase(); + gChatStore = null; + } +} + +async function test_ChatStorage_setup() { + Services.prefs.setBoolPref("browser.aiwindow.removeDatabaseOnStartup", true); + + gChatStore = new ChatStore(); + await gChatStore.destroyDatabase(); + + gSandbox = lazy.sinon.createSandbox(); +} + +async function test_cleanUp() { + Services.prefs.clearUserPref("browser.aiwindow.removeDatabaseOnStartup"); + + await cleanUpDatabase(); + gSandbox.restore(); +} + +add_atomic_task(async function task_ChatStorage_constructor() { + gChatStore = new ChatStore(); + + Assert.ok(gChatStore, "Should return a ChatStorage instance"); +}); + +add_atomic_task(async function test_ChatStorage_updateConversation() { + let success = true; + let errorMessage = ""; + + try { + gChatStore = new ChatStore(); + const conversation = new ChatConversation({}); + + conversation.addUserMessage("test content", "https://www.firefox.com", 0); + + await gChatStore.updateConversation(conversation); + } catch (e) { + success = false; + errorMessage = e.message; + } + + Assert.ok(success, errorMessage); +}); + +add_atomic_task(async function test_ChatStorage_findRecentConversations() { + gChatStore = new ChatStore(); + + await addBasicConvoTestData("1/1/2025", "conversation 1"); + await addBasicConvoTestData("1/2/2025", "conversation 2"); + await addBasicConvoTestData("1/3/2025", "conversation 3"); + + const recentConversations = await gChatStore.findRecentConversations(2); + + Assert.withSoftAssertions(function (soft) { + soft.equal(recentConversations[0].title, "conversation 3"); + soft.equal(recentConversations[1].title, "conversation 2"); + }); +}); + +add_atomic_task(async function test_ChatStorage_findConversationById() { + gChatStore = new ChatStore(); + + let conversation = new ChatConversation({}); + conversation.title = "conversation 1"; + conversation.addUserMessage("test content", "https://www.firefox.com", 0); + await gChatStore.updateConversation(conversation); + + const conversationId = conversation.id; + + conversation = await gChatStore.findConversationById(conversationId); + + Assert.withSoftAssertions(function (soft) { + soft.equal(conversation.id, conversationId); + soft.equal(conversation.title, "conversation 1"); + }); +}); + +add_atomic_task(async function test_ChatStorage_findConversationsByDate() { + gChatStore = new ChatStore(); + + await addBasicConvoWithSpecificUpdatedTestData("1/1/2025", "conversation 1"); + await addBasicConvoWithSpecificUpdatedTestData("6/1/2025", "conversation 2"); + await addBasicConvoWithSpecificUpdatedTestData("12/1/2025", "conversation 3"); + + const startDate = new Date("5/1/2025").getTime(); + const endDate = new Date("1/1/2026").getTime(); + const conversations = await gChatStore.findConversationsByDate( + startDate, + endDate + ); + + const errorMessage = `Incorrect message sorting: ${JSON.stringify(conversations)}`; + + Assert.withSoftAssertions(function (soft) { + soft.equal( + conversations.length, + 2, + "Incorrect number of conversations received" + ); + soft.equal(conversations[0].title, "conversation 3", errorMessage); + soft.equal(conversations[1].title, "conversation 2", errorMessage); + }); +}); + +add_atomic_task(async function test_ChatStorage_findConversationsByURL() { + async function addTestData() { + await addConvoWithSpecificTestData( + new Date("1/1/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.com"), + "conversation 1" + ); + + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 1" + ); + + await addConvoWithSpecificTestData( + new Date("1/3/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 2" + ); + } + + gChatStore = new ChatStore(); + + await addTestData(); + + const conversations = await gChatStore.findConversationsByURL( + new URL("https://www.mozilla.org") + ); + + Assert.withSoftAssertions(function (soft) { + soft.equal(conversations.length, 2, "Chat conversations not found"); + soft.equal(conversations[0].title, "Mozilla.org conversation 2"); + soft.equal(conversations[1].title, "Mozilla.org conversation 1"); + }); +}); + +async function addTestDataForFindMessageByDate() { + await gChatStore.updateConversation( + new ChatConversation({ + title: "convo 1", + description: "", + pageUrl: new URL("https://www.firefox.com"), + pageMeta: {}, + messages: [ + new ChatMessage({ + createdDate: new Date("1/1/2025").getTime(), + ordinal: 0, + role: 0, + content: { type: "text", content: "a message" }, + pageUrl: new URL("https://www.mozilla.com"), + }), + ], + }) + ); + + await gChatStore.updateConversation( + new ChatConversation({ + title: "convo 2", + description: "", + pageUrl: new URL("https://www.firefox.com"), + pageMeta: {}, + messages: [ + new ChatMessage({ + createdDate: new Date("7/1/2025").getTime(), + ordinal: 0, + role: 0, + content: { type: "text", content: "a message in july" }, + pageUrl: new URL("https://www.mozilla.com"), + }), + ], + }) + ); + + await gChatStore.updateConversation( + new ChatConversation({ + title: "convo 3", + description: "", + pageUrl: new URL("https://www.firefox.com"), + pageMeta: {}, + messages: [ + new ChatMessage({ + createdDate: new Date("8/1/2025").getTime(), + ordinal: 0, + role: 1, + content: { type: "text", content: "a message in august" }, + pageUrl: new URL("https://www.mozilla.com"), + }), + ], + }) + ); +} + +add_atomic_task( + async function test_withoutSpecifiedRole_ChatStorage_findMessagesByDate() { + gChatStore = new ChatStore(); + + await addTestDataForFindMessageByDate(); + + const startDate = new Date("6/1/2025"); + const endDate = new Date("1/1/2026"); + const messages = await gChatStore.findMessagesByDate(startDate, endDate); + + Assert.withSoftAssertions(function (soft) { + soft.equal(messages.length, 2, "Chat messages not found"); + soft.equal(messages?.[0]?.content?.content, "a message in august"); + soft.equal(messages?.[1]?.content?.content, "a message in july"); + }); + } +); + +add_atomic_task(async function test_limit_ChatStorage_findMessagesByDate() { + gChatStore = new ChatStore(); + + await addTestDataForFindMessageByDate(); + + const startDate = new Date("6/1/2025"); + const endDate = new Date("1/1/2026"); + const messages = await gChatStore.findMessagesByDate( + startDate, + endDate, + -1, + 1 + ); + + Assert.withSoftAssertions(function (soft) { + soft.equal(messages.length, 1, "Chat messages not found"); + soft.equal(messages?.[0]?.content?.content, "a message in august"); + }); +}); + +add_atomic_task(async function test_skip_ChatStorage_findMessagesByDate() { + gChatStore = new ChatStore(); + + await addTestDataForFindMessageByDate(); + + const startDate = new Date("6/1/2025"); + const endDate = new Date("1/1/2026"); + const messages = await gChatStore.findMessagesByDate( + startDate, + endDate, + -1, + -1, + 1 + ); + + Assert.withSoftAssertions(function (soft) { + soft.equal(messages.length, 1, "Chat messages not found"); + soft.equal(messages?.[0]?.content?.content, "a message in july"); + }); +}); + +add_atomic_task( + async function test_withSpecifiedRole_ChatStorage_findMessagesByDate() { + gChatStore = new ChatStore(); + + await addTestDataForFindMessageByDate(); + + const startDate = new Date("6/1/2025"); + const endDate = new Date("1/1/2026"); + const messages = await gChatStore.findMessagesByDate(startDate, endDate, 0); + + Assert.withSoftAssertions(function (soft) { + soft.equal(messages.length, 1, "Chat messages not found"); + soft.equal(messages?.[0]?.content?.content, "a message in july"); + }); + } +); + +add_atomic_task(async function test_ChatStorage_searchContent() { + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 1", + "a random message" + ); + + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 2", + "a random message again" + ); + + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 3", + "the interesting message" + ); + + const conversations = await gChatStore.searchContent("body"); + + Assert.equal(conversations.length, 3); +}); + +add_atomic_task(async function test_deepPath_ChatStorage_searchContent() { + async function addTestData() { + await addConvoWithSpecificCustomContentTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 1", + { type: "text", content: "a random message" }, + 0 // MessageRole.USER + ); + + await addConvoWithSpecificCustomContentTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 2", + { type: "text", content: "a random message again" }, + 0 // MessageRole.USER + ); + + await addConvoWithSpecificCustomContentTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 3", + { + type: "text", + someKey: { + deeper: { + keyToLookIn: "the interesting message", + }, + }, + }, + 0 // MessageRole.USER + ); + } + + await addTestData(); + + const conversations = await gChatStore.searchContent( + "someKey.deeper.keyToLookIn" + ); + + const foundConvo = conversations[0]; + const firstMessage = foundConvo?.messages?.[0]; + const contentJson = firstMessage?.content; + + Assert.withSoftAssertions(function (soft) { + soft.equal(conversations.length, 1); + soft.equal( + contentJson?.someKey?.deeper?.keyToLookIn, + "the interesting message" + ); + }); +}); + +add_atomic_task(async function test_ChatStorage_search() { + async function addTestData() { + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 1", + "a random message" + ); + + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org interesting conversation 2", + "a random message again" + ); + + await addConvoWithSpecificTestData( + new Date("1/2/2025"), + new URL("https://www.firefox.com"), + new URL("https://www.mozilla.org"), + "Mozilla.org conversation 3", + "some other message" + ); + } + + await addTestData(); + + const conversations = await gChatStore.search("interesting"); + + Assert.withSoftAssertions(function (soft) { + soft.equal(conversations.length, 1); + soft.equal( + conversations[0].title, + "Mozilla.org interesting conversation 2" + ); + + const message = conversations[0].messages[0]; + soft.equal(message.content.body, "a random message again"); + }); +}); + +add_atomic_task(async function test_ChatStorage_deleteConversationById() { + await addBasicConvoTestData("1/1/2025", "a conversation"); + + let conversations = await gChatStore.findRecentConversations(10); + + Assert.equal( + conversations.length, + 1, + "Test conversation for deleteConversationById() did not save." + ); + + const conversation = conversations[0]; + + await gChatStore.deleteConversationById(conversation.id); + conversations = await gChatStore.findRecentConversations(10); + Assert.equal(conversations.length, 0, "Test conversation was not deleted"); +}); + +// TODO: Disabled this test. pruneDatabase() needs some work to switch +// db file size to be checked via dbstat. Additionally, after switching +// the last line to `PRAGMA incremental_vacuum;` the disk storage is +// not immediately freed, so this test is now failing. Will need to +// revisit this test when pruneDatabase() is updated. +// +// add_atomic_task(async function test_ChatStorage_pruneDatabase() { +// const initialDbSize = await gChatStore.getDatabaseSize(); +// +// // NOTE: Add enough conversations to increase the SQLite file +// // by a measurable size +// for (let i = 0; i < 1000; i++) { +// await addBasicConvoTestData("1/1/2025", "a conversation"); +// } +// +// const dbSizeWithTestData = await gChatStore.getDatabaseSize(); +// +// Assert.greater( +// dbSizeWithTestData, +// initialDbSize, +// "Test conversations not saved for pruneDatabase() test" +// ); +// +// await gChatStore.pruneDatabase(0.5, 100000); +// +// const dbSizeAfterPrune = await gChatStore.getDatabaseSize(); +// +// const proximityToInitialSize = dbSizeAfterPrune - initialDbSize; +// const proximityToTestDataSize = dbSizeWithTestData - initialDbSize; +// +// Assert.less( +// proximityToInitialSize, +// proximityToTestDataSize, +// "The pruned size is not closer to the initial db size than it is to the size with test data in it" +// ); +// }); + +add_atomic_task(async function test_applyMigrations_notCalledOnInitialSetup() { + lazy.sinon.stub(gChatStore, "CURRENT_SCHEMA_VERSION").returns(0); + lazy.sinon.spy(gChatStore, "applyMigrations"); + + // Trigger connection to db so file creates and migrations applied + await gChatStore.getDatabaseSize(); + + Assert.ok(gChatStore.applyMigrations.notCalled); +}); + +add_atomic_task( + async function test_applyMigrations_calledOnceIfSchemaIsGreaterThanDb() { + lazy.sinon.stub(gChatStore, "CURRENT_SCHEMA_VERSION").get(() => 2); + lazy.sinon.stub(gChatStore, "getDatabaseSchemaVersion").resolves(1); + lazy.sinon.stub(gChatStore, "applyMigrations"); + lazy.sinon.stub(gChatStore, "setSchemaVersion"); + + // Trigger connection to db so file creates and migrations applied + await gChatStore.getDatabaseSize(); + + Assert.withSoftAssertions(function (soft) { + soft.ok(gChatStore.applyMigrations.calledOnce); + soft.ok(gChatStore.setSchemaVersion.calledWith(2)); + }); + } +); + +add_atomic_task( + async function test_applyMigrations_notCalledIfCurrentSchemaIsLessThanDbSchema_dbDowngrades() { + lazy.sinon.stub(gChatStore, "CURRENT_SCHEMA_VERSION").get(() => 1); + lazy.sinon.stub(gChatStore, "getDatabaseSchemaVersion").resolves(2); + lazy.sinon.stub(gChatStore, "applyMigrations"); + lazy.sinon.stub(gChatStore, "setSchemaVersion"); + + // Trigger connection to db so file creates and migrations applied + await gChatStore.getDatabaseSize(); + + Assert.withSoftAssertions(function (soft) { + soft.ok( + gChatStore.applyMigrations.notCalled, + "applyMigrations was called" + ); + soft.ok( + gChatStore.setSchemaVersion.calledWith(1), + "setSchemaVersion was not called with 1" + ); + }); + } +); diff --git a/browser/components/aiwindow/ui/test/xpcshell/test_chat-utils.js b/browser/components/aiwindow/ui/test/xpcshell/test_chat-utils.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +do_get_profile(); + +const { ChatConversation, ChatMessage } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatStore.sys.mjs" +); +const { + makeGuid, + parseConversationRow, + parseMessageRows, + parseJSONOrNull, + getRoleLabel, +} = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/ui/modules/ChatUtils.sys.mjs" +); + +add_task(function test_makeGuid() { + const guid = makeGuid(); + + Assert.equal(guid.length, 12); +}); + +/** + * Mock row + */ +class RowStub { + constructor(data) { + this.data = data; + } + + getResultByName(key) { + if (Object.hasOwn(this.data, key)) { + return this.data[key]; + } + + throw new Error("NS_ERROR_NOT_AVAILABLE"); + } +} + +add_task(function test_parseConversationRow() { + const now = Date.now(); + const testRow = new RowStub({ + conv_id: "123456789012", + title: "the title", + description: "the description", + page_url: "https://www.firefox.com", + page_meta: '{"hello": "world"}', + created_date: now, + updated_date: now, + status: "a status", + }); + + const conversation = parseConversationRow(testRow); + + Assert.withSoftAssertions(function (soft) { + // eslint-disable-next-line mozilla/use-isInstance + soft.ok(conversation instanceof ChatConversation); + soft.equal(conversation.id, "123456789012"); + soft.equal(conversation.title, "the title"); + soft.equal(conversation.description, "the description"); + soft.ok(URL.isInstance(conversation.pageUrl)); + soft.equal(conversation.pageUrl.href, "https://www.firefox.com/"); + soft.deepEqual(conversation.pageMeta, { hello: "world" }); + soft.equal(conversation.createdDate, now); + soft.equal(conversation.updatedDate, now); + soft.equal(conversation.status, "a status"); + }); +}); + +add_task(function test_missingField_parseConversationRow() { + const now = Date.now(); + const testRow = new RowStub({ + title: "the title", + description: "the description", + page_url: "https://www.firefox.com", + page_meta: '{"hello": "world"}', + created_date: now, + updated_date: now, + status: "a status", + }); + + Assert.throws(function () { + parseConversationRow(testRow); + }, /NS_ERROR_NOT_AVAILABLE/); +}); + +add_task(function test_parseConversationRow() { + const now = Date.now(); + const testRow = new RowStub({ + message_id: "123456789012", + created_date: now, + parent_message_id: "123456", + revision_root_message_id: "1234", + ordinal: 0, + is_active_branch: true, + role: 0, + model_id: "a model id", + params: '{ "some": "data" }', + usage: '{ "some": "usage data" }', + content: '{ "some": "content data" }', + conv_id: "123456789012", + page_url: "https://www.firefox.com", + turn_index: 0, + insights_enabled: true, + insights_flag_source: 1, + insights_applied: '{ "some": "insights" }', + web_search_queries: '{ "some": "web search queries" }', + }); + + const rows = parseMessageRows([testRow]); + const message = rows[0]; + + Assert.withSoftAssertions(function (soft) { + // eslint-disable-next-line mozilla/use-isInstance + soft.ok(message instanceof ChatMessage); + soft.equal(message.id, "123456789012"); + soft.equal(message.createdDate, now); + soft.equal(message.parentMessageId, "123456"); + soft.equal(message.revisionRootMessageId, "1234"); + soft.equal(message.ordinal, 0); + soft.equal(message.isActiveBranch, true); + soft.equal(message.role, 0); + soft.equal(message.modelId, "a model id"); + soft.deepEqual(message.params, { some: "data" }); + soft.deepEqual(message.usage, { some: "usage data" }); + soft.deepEqual(message.content, { some: "content data" }); + soft.deepEqual(message.convId, "123456789012"); + soft.ok(URL.isInstance(message.pageUrl)); + soft.deepEqual(message.pageUrl.href, "https://www.firefox.com/"); + soft.equal(message.turnIndex, 0); + soft.equal(message.insightsEnabled, true); + soft.equal(message.insightsFlagSource, 1); + soft.deepEqual(message.insightsApplied, { some: "insights" }); + soft.deepEqual(message.webSearchQueries, { some: "web search queries" }); + }); +}); + +add_task(function test_missingField_parseConversationRow() { + const now = Date.now(); + const testRow = new RowStub({ + //message_id: "123456789012", + created_date: now, + parent_message_id: "123456", + revision_root_message_id: "1234", + ordinal: 0, + is_active_branch: true, + role: 0, + model_id: "a model id", + params: '{ "some": "data" }', + usage: '{ "some": "usage data" }', + content: '{ "some": "content data" }', + conv_id: "123456789012", + page_url: "https://www.firefox.com", + turn_index: 0, + insights_enabled: true, + insights_flag_source: 1, + insights_applied: '{ "some": "insights" }', + web_search_queries: '{ "some": "web search queries" }', + }); + + Assert.throws(function () { + parseMessageRows([testRow]); + }, /NS_ERROR_NOT_AVAILABLE/); +}); + +add_task(function test_parseJSONOrNull() { + const json = '{"some": "data"}'; + + const parsed = parseJSONOrNull(json); + + Assert.deepEqual(parsed, { some: "data" }); +}); + +add_task(function test_invalidJson_parseJSONOrNull() { + const json = '{some: "data"}'; + + const parsed = parseJSONOrNull(json); + + Assert.equal(parsed, null); +}); + +add_task(function test_user_getRoleLabel() { + const role = getRoleLabel(0); + + Assert.equal(role, "User"); +}); + +add_task(function test_assistant_getRoleLabel() { + const role = getRoleLabel(1); + + Assert.equal(role, "Assistant"); +}); + +add_task(function test_system_getRoleLabel() { + const role = getRoleLabel(2); + + Assert.equal(role, "System"); +}); + +add_task(function test_user_getRoleLabel() { + const role = getRoleLabel(3); + + Assert.equal(role, "Tool"); +}); diff --git a/browser/components/aiwindow/ui/test/xpcshell/xpcshell.toml b/browser/components/aiwindow/ui/test/xpcshell/xpcshell.toml @@ -0,0 +1,14 @@ +[DEFAULT] +firefox-appdir = "browser" +head = ["asserts.js"] +run-if = [ + "os != 'android'", +] + +["test_ChatConversation.js"] + +["test_ChatMessage.js"] + +["test_ChatStore.js"] + +["test_chat-utils.js"]