Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 15 additions & 8 deletions app/scripts/background/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@
signal
};

if (Array.isArray(options.initialPrompts) && options.initialPrompts.length > 0) {
sessionOptions.initialPrompts = options.initialPrompts;
}

// Add download progress monitoring if callback provided
if (options.onProgress) {
sessionOptions.monitor = function(m) {
Expand Down Expand Up @@ -303,13 +307,17 @@
}

try {
// Destroy existing session if any
// Create the new session first; only swap out the old one on success.
// Otherwise a failure here would leave promptAPISession null and every
// subsequent prompt would return "No active session".
const newSession = await initPromptAPISession({
initialPrompts: data && data.initialPrompts
}, new AbortController().signal);

if (promptAPISession) {
promptAPISession.destroy();
promptAPISession = null;
}

promptAPISession = await initPromptAPISession({}, new AbortController().signal);
promptAPISession = newSession;

port.postMessage({
type: 'session-created'
Expand All @@ -328,11 +336,10 @@
*/
async function handlePromptStreaming(data, port) {

// Validate messages structure
if (!data || !Array.isArray(data.messages)) {
if (!data || typeof data.userMessage !== 'string') {
port.postMessage({
type: 'error',
message: 'Invalid messages format: expected array'
message: 'Invalid prompt: expected userMessage string'
});
return;
}
Expand All @@ -353,7 +360,7 @@

try {
const stream = await promptAPISession.promptStreaming(
data.messages,
data.userMessage,
{ signal: promptAPIController.signal }
);

Expand Down
50 changes: 19 additions & 31 deletions app/scripts/modules/ai/AISessionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,11 @@ Be neutral, direct, and developer-focused. Avoid marketing language, unnecessary
};

/**
* Create a new AI session (empty, stateless).
* Create a new AI session with optional initial prompts (system + history).
* @param {Array} initialPrompts - Optional [{role, content}, ...]; first should be 'system'.
* @returns {Promise<boolean>} - True if session created successfully
*/
AISessionManager.prototype.createSession = function () {
AISessionManager.prototype.createSession = function (initialPrompts) {
return new Promise((resolve, reject) => {
this._connect();

Expand All @@ -199,7 +200,9 @@ AISessionManager.prototype.createSession = function () {

this._send({
type: 'create-session',
data: {}
data: {
initialPrompts: initialPrompts || []
}
});
});
};
Expand Down Expand Up @@ -300,39 +303,24 @@ AISessionManager.prototype._formatPrompt = function (prompt, context) {
};

/**
* Build messages array for prompt API.
* @private
* @param {string} userMessage - Current user message
* @param {Array} conversationHistory - Previous messages [{ role, content }]
* @param {Object} context - Optional context (control data, appInfo)
* @returns {Array} - Messages array [{ role, content }]
* Build the system prompt content for a given app context.
* @param {Object} appInfo - Application information
* @returns {string}
*/
AISessionManager.prototype._buildMessagesArray = function (userMessage, conversationHistory, context) {
const messages = [];

const systemPrompt = this._getDefaultSystemPrompt(context ? context.appInfo : null);
messages.push({ role: 'system', content: systemPrompt });

if (conversationHistory && Array.isArray(conversationHistory)) {
conversationHistory.forEach(msg => {
messages.push({ role: msg.role, content: msg.content });
});
}

const formattedMessage = this._formatPrompt(userMessage, context);
messages.push({ role: 'user', content: formattedMessage });

return messages;
AISessionManager.prototype.buildSystemPrompt = function (appInfo) {
return this._getDefaultSystemPrompt(appInfo);
};

/**
* Send a prompt and get a streaming response.
* The Chrome Prompt API session retains its own conversation history,
* so only the new user message is sent here. System prompt and prior
* turns are seeded via initialPrompts at session creation time.
* @param {string} userMessage - Current user message
* @param {Array} conversationHistory - Previous messages [{ role, content }]
* @param {Object} context - Optional context (control data, appInfo)
* @param {Object} context - Optional context (control data) for prompt formatting
* @returns {Promise<Object>} - Object with methods to handle streaming
*/
AISessionManager.prototype.promptStreaming = function (userMessage, conversationHistory, context) {
AISessionManager.prototype.promptStreaming = function (userMessage, context) {
return new Promise((resolve, reject) => {
this._connect();

Expand All @@ -341,7 +329,7 @@ AISessionManager.prototype.promptStreaming = function (userMessage, conversation
return;
}

const messages = this._buildMessagesArray(userMessage, conversationHistory, context);
const formattedMessage = this._formatPrompt(userMessage, context);
let streamHandlers = {
onChunk: null,
onComplete: null,
Expand Down Expand Up @@ -439,11 +427,11 @@ AISessionManager.prototype.promptStreaming = function (userMessage, conversation
this._on('complete', completeHandler);
this._on('error', errorHandler);

// Send messages array
// Send only the new user message — session retains prior history.
this._send({
type: 'prompt-streaming',
data: {
messages: messages
userMessage: formattedMessage
}
});

Expand Down
56 changes: 46 additions & 10 deletions app/scripts/modules/ui/AIChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function AIChat(containerId, options) {
this._currentContext = null;
this._messages = [];
this._isStreaming = false;
this._isReseedingSession = false;
this._streamingMessageElement = null;
this._streamingMessageHeader = null;
this._getAppInfo = options.getAppInfo || null;
Expand Down Expand Up @@ -209,12 +210,32 @@ AIChat.prototype._checkModelAvailability = async function () {
};

/**
* Initialize AI session.
* Initialize AI session, seeding system prompt + any prior conversation
* (so the model "remembers" history loaded from storage).
* @private
*/
AIChat.prototype._initializeSession = async function () {
try {
await this._sessionManager.createSession();
var appInfo = null;
if (this._getAppInfo) {
appInfo = this._getAppInfo();
} else if (this._currentContext) {
appInfo = this._currentContext.appInfo;
}

const initialPrompts = [
{ role: 'system', content: this._sessionManager.buildSystemPrompt(appInfo) }
];

// Replay prior user/assistant turns; skip UI-only 'system' notices
// and empty placeholders (e.g. the assistant slot added mid-stream).
this._messages.forEach(m => {
if ((m.role === 'user' || m.role === 'assistant') && m.content) {
initialPrompts.push({ role: m.role, content: m.content });
}
});

await this._sessionManager.createSession(initialPrompts);
document.getElementById('ai-clear-history-button').style.display = 'inline-block';
this._updateTokenCounter();
} catch (error) {
Expand Down Expand Up @@ -313,9 +334,6 @@ AIChat.prototype._handleSendMessage = async function () {
loadingIndicator.appendChild(loadingDots);
this._streamingMessageElement.appendChild(loadingIndicator);

// Build conversation history (exclude the placeholder we just added)
const conversationHistory = this._messages.slice(0, -1);

// Get app info for context
var appInfo = null;
if (this._getAppInfo) {
Expand All @@ -330,10 +348,9 @@ AIChat.prototype._handleSendMessage = async function () {
appInfo: appInfo
};

// Get streaming response
// Get streaming response — session retains history internally.
const stream = await this._sessionManager.promptStreaming(
userMessage,
conversationHistory,
context
);

Expand Down Expand Up @@ -1051,13 +1068,19 @@ AIChat.prototype.setUrl = function (url) {
* @private
*/
AIChat.prototype._loadHistory = async function () {
if (this._isStreaming || this._isReseedingSession) {
return;
}
try {
const messages = await this._storageManager.loadHistory(this._currentUrl);

if (messages.length > 0) {
const messagesContainer = document.getElementById('ai-messages-container');
messagesContainer.innerHTML = '';
// Reset in-memory state before repopulating from storage —
// _addMessage always pushes, so without this every tab switch duplicates.
this._messages = [];
const messagesContainer = document.getElementById('ai-messages-container');
messagesContainer.innerHTML = '';

if (messages.length > 0) {
messages.forEach(msg => {
this._addMessage(msg.role, msg.content);
});
Expand All @@ -1066,7 +1089,20 @@ AIChat.prototype._loadHistory = async function () {
// Force scroll to bottom when loading history
this._scrollToBottom(true);
}

// Re-seed session regardless of history length: a fresh app context
// (different framework version, theme, libraries) needs a new system prompt.
if (this._sessionManager.hasActiveSession()) {
this._isReseedingSession = true;
try {
this._sessionManager.destroy();
await this._initializeSession();
} finally {
this._isReseedingSession = false;
}
}
} catch (error) {
this._isReseedingSession = false;
// Fail silently
}
};
Expand Down
45 changes: 11 additions & 34 deletions tests/modules/ai/AISessionManager.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,46 +184,23 @@ describe('AISessionManager', function () {
});
});

describe('#_buildMessagesArray()', function () {
it('should include system prompt', function () {
var messages = sessionManager._buildMessagesArray('Hello', [], null);
describe('#buildSystemPrompt()', function () {
it('should return the default system prompt', function () {
var prompt = sessionManager.buildSystemPrompt(null);

messages[0].role.should.equal('system');
messages[0].content.should.contain('AI assistant');
});

it('should include conversation history', function () {
var history = [
{ role: 'user', content: 'First question' },
{ role: 'assistant', content: 'First answer' }
];

var messages = sessionManager._buildMessagesArray('Second question', history, null);

messages.should.have.lengthOf(4);
messages[1].content.should.equal('First question');
messages[2].content.should.equal('First answer');
prompt.should.contain('AI assistant');
});

it('should format user message with context', function () {
var context = {
control: {
type: 'sap.m.Button',
id: 'btn1'
}
it('should include app context when appInfo provided', function () {
var appInfo = {
common: { data: { OpenUI5: '1.120.0' } },
configurationComputed: { data: { theme: 'sap_horizon' } }
};

var messages = sessionManager._buildMessagesArray('What is this?', [], context);

messages[1].role.should.equal('user');
messages[1].content.should.contain('Type: sap.m.Button');
messages[1].content.should.contain('What is this?');
});

it('should handle empty conversation history', function () {
var messages = sessionManager._buildMessagesArray('Hello', null, null);
var prompt = sessionManager.buildSystemPrompt(appInfo);

messages.should.have.lengthOf(2);
prompt.should.contain('1.120.0');
prompt.should.contain('sap_horizon');
});
});

Expand Down
Loading