tor-browser

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

commit 53df932c3d25e57cb8bb2eed1118b891c2aa78e3
parent 6f0b6420c08fd4c35c7dc42a25421495523e9985
Author: serge-sans-paille <sguelton@mozilla.com>
Date:   Wed, 10 Dec 2025 20:10:36 +0000

Bug 2003303 - Implement Title Generation r=tzhang,ai-models-reviewers,ngrato

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

Diffstat:
Abrowser/components/aiwindow/models/TitleGeneration.sys.mjs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/moz.build | 1+
Abrowser/components/aiwindow/models/prompts/TitleGenerationPrompts.sys.mjs | 16++++++++++++++++
Mbrowser/components/aiwindow/models/prompts/moz.build | 1+
Abrowser/components/aiwindow/models/tests/xpcshell/test_TitleGeneration.js | 434+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/aiwindow/models/tests/xpcshell/xpcshell.toml | 2++
6 files changed, 529 insertions(+), 0 deletions(-)

diff --git a/browser/components/aiwindow/models/TitleGeneration.sys.mjs b/browser/components/aiwindow/models/TitleGeneration.sys.mjs @@ -0,0 +1,75 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { + openAIEngine, + renderPrompt, +} from "moz-src:///browser/components/aiwindow/models/Utils.sys.mjs"; +import { titleGenerationPrompt } from "moz-src:///browser/components/aiwindow/models/prompts/TitleGenerationPrompts.sys.mjs"; + +/** + * Generats a default title from the first four words of a message. + * + * @param {string} message - The user's message + * @returns {string} The default title + */ +function generateDefaultTitle(message) { + if (!message || typeof message !== "string") { + return "New Chat"; + } + + const words = message + .trim() + .split(/\s+/) + .filter(word => !!word.length); + + if (words.length === 0) { + return "New Chat"; + } + + const titleWords = words.slice(0, 4); + return titleWords.join(" ") + "..."; +} + +/** + * Generate a chat title based on the user's message and current tab information. + * + * @param {string} message - The user's message + * @param {object} current_tab - Object containing current tab information + * @returns {Promise<string>} The generated chat title + */ +export async function generateChatTitle(message, current_tab) { + try { + // Build the OpenAI engines + const engine = await openAIEngine.build(); + + const tabInfo = current_tab || { url: "", title: "", description: "" }; + + // Render the prompt with actual values + const systemPrompt = await renderPrompt(titleGenerationPrompt, { + current_tab: JSON.stringify(tabInfo), + }); + + // Prepare messages for the LLM + const messages = [ + { role: "system", content: systemPrompt }, + { role: "user", content: message }, + ]; + + // Call the LLM + const response = await engine.run({ messages }); + + // Extract the generated title from the response + const title = + response?.choices?.[0]?.message?.content?.trim() || + generateDefaultTitle(message); + + return title; + } catch (error) { + console.error("Failed to generate chat title:", error); + return generateDefaultTitle(message); + } +} diff --git a/browser/components/aiwindow/models/moz.build b/browser/components/aiwindow/models/moz.build @@ -19,6 +19,7 @@ MOZ_SRC_FILES += [ "InsightsSchemas.sys.mjs", "IntentClassifier.sys.mjs", "SearchBrowsingHistory.sys.mjs", + "TitleGeneration.sys.mjs", "Tools.sys.mjs", "Utils.sys.mjs", ] diff --git a/browser/components/aiwindow/models/prompts/TitleGenerationPrompts.sys.mjs b/browser/components/aiwindow/models/prompts/TitleGenerationPrompts.sys.mjs @@ -0,0 +1,16 @@ +/* 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/. */ + +export const titleGenerationPrompt = `Generate a concise chat title using only the current user message and the current context. + +Rules: +- Fewer than 6 words; reflect the main topic/intent +- Do not end with punctuation +- Do not write questions +- No quotes, brackets, or emojis + +Inputs: +The user is currently viewing this tab page: {current_tab} + +Output: Only the title.`; diff --git a/browser/components/aiwindow/models/prompts/moz.build b/browser/components/aiwindow/models/prompts/moz.build @@ -8,4 +8,5 @@ with Files("**"): MOZ_SRC_FILES += [ "AssistantPrompts.sys.mjs", "insightsPrompts.sys.mjs", + "TitleGenerationPrompts.sys.mjs", ] diff --git a/browser/components/aiwindow/models/tests/xpcshell/test_TitleGeneration.js b/browser/components/aiwindow/models/tests/xpcshell/test_TitleGeneration.js @@ -0,0 +1,434 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { generateChatTitle } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/TitleGeneration.sys.mjs" +); + +const { openAIEngine } = ChromeUtils.importESModule( + "moz-src:///browser/components/aiwindow/models/Utils.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +/** + * Constants for preference keys and test values + */ +const PREF_API_KEY = "browser.aiwindow.apiKey"; +const PREF_ENDPOINT = "browser.aiwindow.endpoint"; +const PREF_MODEL = "browser.aiwindow.model"; + +const API_KEY = "test-api-key"; +const ENDPOINT = "https://api.test-endpoint.com/v1"; +const MODEL = "test-model"; + +/** + * Cleans up preferences after testing + */ +registerCleanupFunction(() => { + for (let pref of [PREF_API_KEY, PREF_ENDPOINT, PREF_MODEL]) { + if (Services.prefs.prefHasUserValue(pref)) { + Services.prefs.clearUserPref(pref); + } + } +}); + +/** + * Test that generateChatTitle successfully generates a title + */ +add_task(async function test_generateChatTitle_success() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + // Mock the engine response + const mockResponse = { + choices: [ + { + message: { + content: "Weather Forecast Query", + }, + }, + ], + }; + + const fakeEngineInstance = { + run: sb.stub().resolves(mockResponse), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + const message = "What's the weather like today?"; + const currentTab = { + url: "https://weather.example.com", + title: "Weather Forecast", + description: "Get current weather conditions", + }; + + const title = await generateChatTitle(message, currentTab); + + Assert.equal( + title, + "Weather Forecast Query", + "Should return the generated title from the LLM" + ); + + Assert.ok( + fakeEngineInstance.run.calledOnce, + "Engine run should be called once" + ); + + // Verify the messages structure passed to the engine + const callArgs = fakeEngineInstance.run.firstCall.args[0]; + Assert.ok(callArgs.messages, "Should pass messages to the engine"); + Assert.equal( + callArgs.messages.length, + 2, + "Should have system and user messages" + ); + Assert.equal( + callArgs.messages[0].role, + "system", + "First message should be system" + ); + Assert.equal( + callArgs.messages[1].role, + "user", + "Second message should be user" + ); + Assert.equal( + callArgs.messages[1].content, + message, + "User message should contain the input message" + ); + + // Verify the system prompt contains the tab information + const systemContent = callArgs.messages[0].content; + Assert.ok( + systemContent.includes(currentTab.url), + "System prompt should include tab URL" + ); + Assert.ok( + systemContent.includes(currentTab.title), + "System prompt should include tab title" + ); + Assert.ok( + systemContent.includes(currentTab.description), + "System prompt should include tab description" + ); + } finally { + sb.restore(); + } +}); + +/** + * Test that generateChatTitle handles missing tab information + */ +add_task(async function test_generateChatTitle_no_tab_info() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + const mockResponse = { + choices: [ + { + message: { + content: "General Question", + }, + }, + ], + }; + + const fakeEngineInstance = { + run: sb.stub().resolves(mockResponse), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + const message = "Tell me about AI"; + const currentTab = null; + + const title = await generateChatTitle(message, currentTab); + + Assert.equal( + title, + "General Question", + "Should return the generated title even without tab info" + ); + + // Verify the system prompt handles null tab + const callArgs = fakeEngineInstance.run.firstCall.args[0]; + Assert.ok(callArgs.messages, "Should pass messages even with null tab"); + } finally { + sb.restore(); + } +}); + +/** + * Test that generateChatTitle handles empty tab fields + */ +add_task(async function test_generateChatTitle_empty_tab_fields() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + const mockResponse = { + choices: [ + { + message: { + content: "Untitled Chat", + }, + }, + ], + }; + + const fakeEngineInstance = { + run: sb.stub().resolves(mockResponse), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + const message = "Hello"; + const currentTab = { + url: "", + title: "", + description: "", + }; + + const title = await generateChatTitle(message, currentTab); + + Assert.equal(title, "Untitled Chat", "Should handle empty tab fields"); + + // Verify the system prompt includes the empty tab object + const callArgs = fakeEngineInstance.run.firstCall.args[0]; + Assert.ok( + callArgs.messages, + "Should pass messages even with empty tab fields" + ); + } finally { + sb.restore(); + } +}); + +/** + * Test that generateChatTitle handles engine errors gracefully + */ +add_task(async function test_generateChatTitle_engine_error() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + const fakeEngineInstance = { + run: sb.stub().rejects(new Error("Engine failed")), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + const message = "Test message for error handling"; + const currentTab = { + url: "https://example.com", + title: "Example", + description: "Test", + }; + + const title = await generateChatTitle(message, currentTab); + + Assert.equal( + title, + "Test message for error...", + "Should return first four words when engine fails" + ); + } finally { + sb.restore(); + } +}); + +/** + * Test that generateChatTitle handles malformed engine responses + */ +add_task(async function test_generateChatTitle_malformed_response() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + // Test with missing choices + const mockResponse1 = {}; + let fakeEngineInstance = { + run: sb.stub().resolves(mockResponse1), + }; + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + let title = await generateChatTitle("test message one two", null); + Assert.equal( + title, + "test message one two...", + "Should return first four words for missing choices" + ); + + // Test with empty choices array + sb.restore(); + const sb2 = sinon.createSandbox(); + const mockResponse2 = { choices: [] }; + fakeEngineInstance = { + run: sb2.stub().resolves(mockResponse2), + }; + sb2.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + title = await generateChatTitle("another test message here", null); + Assert.equal( + title, + "another test message here...", + "Should return first four words for empty choices" + ); + + // Test with null content + sb2.restore(); + const sb3 = sinon.createSandbox(); + const mockResponse3 = { + choices: [{ message: { content: null } }], + }; + fakeEngineInstance = { + run: sb3.stub().resolves(mockResponse3), + }; + sb3.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + title = await generateChatTitle("short test here", null); + Assert.equal( + title, + "short test here...", + "Should return first four words for null content" + ); + + sb3.restore(); + } finally { + sb.restore(); + } +}); + +/** + * Test that generateChatTitle trims whitespace from response + */ +add_task(async function test_generateChatTitle_trim_whitespace() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + const mockResponse = { + choices: [ + { + message: { + content: " Title With Spaces \n\n", + }, + }, + ], + }; + + const fakeEngineInstance = { + run: sb.stub().resolves(mockResponse), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + const title = await generateChatTitle("test", null); + + Assert.equal( + title, + "Title With Spaces", + "Should trim whitespace from generated title" + ); + } finally { + sb.restore(); + } +}); + +/** + * Test default title generation with fewer than four words + */ +add_task(async function test_generateChatTitle_short_message() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + const fakeEngineInstance = { + run: sb.stub().rejects(new Error("Engine failed")), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + // Test with three words + let title = await generateChatTitle("Hello there friend", null); + Assert.equal( + title, + "Hello there friend...", + "Should return three words with ellipsis" + ); + + // Test with one word + title = await generateChatTitle("Hello", null); + Assert.equal(title, "Hello...", "Should return one word with ellipsis"); + + // Test with empty message + title = await generateChatTitle("", null); + Assert.equal( + title, + "New Chat", + "Should return 'New Chat' for empty message" + ); + + // Test with whitespace only + title = await generateChatTitle(" ", null); + Assert.equal( + title, + "New Chat", + "Should return 'New Chat' for whitespace-only message" + ); + } finally { + sb.restore(); + } +}); + +/** + * Test default title generation with more than four words + */ +add_task(async function test_generateChatTitle_long_message() { + Services.prefs.setStringPref(PREF_API_KEY, API_KEY); + Services.prefs.setStringPref(PREF_ENDPOINT, ENDPOINT); + Services.prefs.setStringPref(PREF_MODEL, MODEL); + + const sb = sinon.createSandbox(); + try { + const fakeEngineInstance = { + run: sb.stub().rejects(new Error("Engine failed")), + }; + + sb.stub(openAIEngine, "_createEngine").resolves(fakeEngineInstance); + + const message = "This is a very long message with many words"; + const title = await generateChatTitle(message, null); + + Assert.equal( + title, + "This is a very...", + "Should return only first four words with ellipsis" + ); + } finally { + sb.restore(); + } +}); diff --git a/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml b/browser/components/aiwindow/models/tests/xpcshell/xpcshell.toml @@ -18,6 +18,8 @@ support-files = [] ["test_SearchBrowsingHistory.js"] +["test_TitleGeneration.js"] + ["test_Tools_GetOpenTabs.js"] ["test_Tools_SearchBrowsingHistory.js"]