Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DO NOT MERGE [JS] feat: custom feedback form + citation changes #2182

Merged
merged 6 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion js/packages/teams-ai/src/AI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export class AI<TState extends TurnState = TurnState> {
* A text string that can be returned from an action to stop the AI system from continuing
* to execute the current plan.
* @remarks
* This command is incompatible and should not be used with `tools` augmentation
* This command is incompatible and should not be used with `tools` augmentation
*/
public static readonly StopCommandName = actions.StopCommandName;

Expand Down
17 changes: 15 additions & 2 deletions js/packages/teams-ai/src/Application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ReadReceiptInfo } from 'botframework-connector';
import { AdaptiveCards, AdaptiveCardsOptions } from './AdaptiveCards';
import { AI, AIOptions } from './AI';
import { Meetings } from './Meetings';
import { Messages } from './Messages';
import { MessageExtensions } from './MessageExtensions';
import { TaskModules, TaskModulesOptions } from './TaskModules';
import { AuthenticationManager, AuthenticationOptions } from './authentication/Authentication';
Expand Down Expand Up @@ -149,12 +150,14 @@ export interface FeedbackLoopData {
/**
* 'like' or 'dislike'
*/
reaction: string;
reaction: 'like' | 'dislike';

/**
* The response the user provides when prompted with "What did you like/dislike?" after pressing one of the feedback buttons.
*/
feedback: string;
feedback: string | Record<string, any>;
};

/**
* The activity ID that the feedback was provided on.
*/
Expand Down Expand Up @@ -241,6 +244,7 @@ export class Application<TState extends TurnState = TurnState> {
private readonly _invokeRoutes: AppRoute<TState>[] = [];
private readonly _adaptiveCards: AdaptiveCards<TState>;
private readonly _meetings: Meetings<TState>;
private readonly _messages: Messages<TState>;
private readonly _messageExtensions: MessageExtensions<TState>;
private readonly _taskModules: TaskModules<TState>;
private readonly _ai?: AI<TState>;
Expand Down Expand Up @@ -283,6 +287,7 @@ export class Application<TState extends TurnState = TurnState> {
}

this._adaptiveCards = new AdaptiveCards<TState>(this);
this._messages = new Messages<TState>(this);
this._messageExtensions = new MessageExtensions<TState>(this);
this._meetings = new Meetings<TState>(this);
this._taskModules = new TaskModules<TState>(this);
Expand Down Expand Up @@ -350,6 +355,14 @@ export class Application<TState extends TurnState = TurnState> {
return this._authentication;
}

/**
* Fluent interface for accessing Messages specific features.
* @returns {Messages<TState>} The Messages instance.
*/
public get messages(): Messages<TState> {
return this._messages;
}

/**
* Fluent interface for accessing Message Extensions' specific features.
* @returns {MessageExtensions<TState>} The MessageExtensions instance.
Expand Down
56 changes: 56 additions & 0 deletions js/packages/teams-ai/src/Messages.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import sinon from 'sinon';
import { strict as assert } from 'assert';
import { ActivityTypes, Channels, INVOKE_RESPONSE_KEY, TestAdapter } from 'botbuilder';

import { Application } from './Application';
import { createTestInvoke } from './internals/testing/TestUtilities';
import { MessageInvokeNames, Messages } from './Messages';

describe('Messages', () => {
const adapter = new TestAdapter();
let mockApp: Application;

beforeEach(() => {
mockApp = new Application();
sinon.stub(mockApp, 'adapter').get(() => adapter);
});

it('should exist when Application is instantiated', () => {
assert.notEqual(mockApp.messages, undefined);
assert.equal(mockApp.messages instanceof Messages, true);
});

describe(MessageInvokeNames.FETCH_INVOKE_NAME, () => {
it('fetch() with custom RouteSelector handler result is falsy', async () => {
const activity = createTestInvoke(MessageInvokeNames.FETCH_INVOKE_NAME, {});
activity.channelId = Channels.Msteams;
mockApp.messages.fetch(async (_context, _state, _data) => {
return {};
});

await adapter.processActivity(activity, async (context) => {
await mockApp.run(context);
const response = context.turnState.get(INVOKE_RESPONSE_KEY);
assert.deepEqual(response.value, {
status: 200,
body: { task: { type: 'continue', value: {} } }
});
});
});

it('fetch() with custom RouteSelector unhappy path', async () => {
const activity = { channelId: Channels.Msteams, type: ActivityTypes.Invoke, name: 'incorrectName' };
const spy = sinon.spy(async (context, _state, _data) => {
return Promise.resolve('');
});

mockApp.messages.fetch(spy);

await adapter.processActivity(activity, async (context) => {
await mockApp.run(context);
});

assert.equal(spy.called, false);
});
});
});
100 changes: 100 additions & 0 deletions js/packages/teams-ai/src/Messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* @module teams-ai
*/
/**
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License.
*/

import {
ActivityTypes,
Channels,
INVOKE_RESPONSE_KEY,
InvokeResponse,
TaskModuleResponse,
TaskModuleTaskInfo,
TurnContext
} from 'botbuilder';
import { Application } from './Application';
import { TurnState } from './TurnState';

export enum MessageInvokeNames {
FETCH_INVOKE_NAME = `message/fetchTask`
}

/**
* TaskModules class to enable fluent style registration of handlers related to Task Modules.
* @template TState Type of the turn state object being persisted.
*/
export class Messages<TState extends TurnState> {
private readonly _app: Application<TState>;

/**
* Creates a new instance of the TaskModules class.
* @param {Application} app Top level application class to register handlers with.
*/
public constructor(app: Application<TState>) {
this._app = app;
}

/**
* Registers a handler to process the initial fetch of the task module.
* @remarks
* Handlers should respond with either an initial TaskInfo object or a string containing
* a message to display to the user.
* @template TData Optional. Type of the data object being passed to the handler.
* @param {(context: TurnContext, state: TState, data: TData) => Promise<TaskModuleTaskInfo | string>} handler - Function to call when the handler is triggered.
* @param {TurnContext} handler.context - Context for the current turn of conversation with the user.
* @param {TState} handler.state - Current state of the turn.
* @param {TData} handler.data - Data object passed to the handler.
* @returns {Application<TState>} The application for chaining purposes.
*/
public fetch<TData extends Record<string, any> = Record<string, any>>(
handler: (context: TurnContext, state: TState, data: TData) => Promise<TaskModuleTaskInfo | string>
): Application<TState> {
this._app.addRoute(
async (context) => {
return (
context?.activity?.type === ActivityTypes.Invoke &&
context?.activity?.name === MessageInvokeNames.FETCH_INVOKE_NAME
);
},
async (context, state) => {
if (context?.activity?.channelId === Channels.Msteams) {
aacebo marked this conversation as resolved.
Show resolved Hide resolved
const result = await handler(context, state, context.activity.value?.data ?? {});

if (!context.turnState.get(INVOKE_RESPONSE_KEY)) {
// Format invoke response
let response: TaskModuleResponse;
if (typeof result == 'string') {
// Return message
response = {
task: {
type: 'message',
value: result
}
};
} else {
// Return card
response = {
task: {
type: 'continue',
value: result
}
};
}

// Queue up invoke response
await context.sendActivity({
value: { body: response, status: 200 } as InvokeResponse,
type: ActivityTypes.InvokeResponse
});
}
}
},
true
);

return this._app;
}
}
2 changes: 1 addition & 1 deletion js/packages/teams-ai/src/StreamingResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export class StreamingResponse {
for (const citation of citations) {
const clientCitation: ClientCitation = {
'@type': 'Claim',
position: `${currPos + 1}`,
position: currPos + 1,
appearance: {
'@type': 'DigitalDocument',
name: citation.title || `Document #${currPos + 1}`,
Expand Down
12 changes: 6 additions & 6 deletions js/packages/teams-ai/src/Utilities.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('Utilities', () => {
const citations = [
{
'@type': 'Claim',
position: '1',
position: 1,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -96,7 +96,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '2',
position: 2,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -113,7 +113,7 @@ describe('Utilities', () => {
const citations = [
{
'@type': 'Claim',
position: '1',
position: 1,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -122,7 +122,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '2',
position: 2,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -131,7 +131,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '3',
position: 3,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand All @@ -140,7 +140,7 @@ describe('Utilities', () => {
},
{
'@type': 'Claim',
position: '4',
position: 4,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand Down
2 changes: 1 addition & 1 deletion js/packages/teams-ai/src/actions/SayCommand.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ describe('actions.sayCommand', () => {
citation: [
{
'@type': 'Claim',
position: '1',
position: 1,
appearance: {
'@type': 'DigitalDocument',
name: 'the title',
Expand Down
2 changes: 1 addition & 1 deletion js/packages/teams-ai/src/actions/SayCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function sayCommand<TState extends TurnState = TurnState>(feedbackLoopEna
citations = data.response.context!.citations.map((citation, i) => {
const clientCitation: ClientCitation = {
'@type': 'Claim',
position: `${i + 1}`,
position: i + 1,
appearance: {
'@type': 'DigitalDocument',
name: citation.title || `Document #${i + 1}`,
Expand Down
48 changes: 40 additions & 8 deletions js/packages/teams-ai/src/types/ClientCitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@

import { SensitivityUsageInfo } from './SensitivityUsageInfo';

export type ClientCitationIconName =
| 'Microsoft Word'
| 'Microsoft Excel'
| 'Microsoft PowerPoint'
| 'Microsoft OneNote'
| 'Microsoft SharePoint'
| 'Microsoft Visio'
| 'Microsoft Loop'
| 'Microsoft Whiteboard'
| 'Adobe Illustrator'
| 'Adobe Photoshop'
| 'Adobe InDesign'
| 'Adobe Flash'
| 'Sketch'
| 'Source Code'
| 'Image'
| 'GIF'
| 'Video'
| 'Sound'
| 'ZIP'
| 'Text'
| 'PDF';

/**
* Represents a Teams client citation to be included in a message. See Bot messages with AI-generated content for more details.
* https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bot-messages-ai-generated-content?tabs=before%2Cbotmessage
Expand All @@ -21,20 +44,21 @@ export interface ClientCitation {
/**
* Required. Number and position of the citation.
*/
position: string;
position: number;
appearance: {
/**
* Required; Must be 'DigitalDocument'
*/
'@type': 'DigitalDocument';

/**
* Name of the document.
* Name of the document. (max length 80)
*/
name: string;

/**
* Optional; ignored in Teams
* Stringified adaptive card with additional information about the citation.
* It is rendered within the modal.
*/
text?: string;

Expand All @@ -44,22 +68,30 @@ export interface ClientCitation {
url?: string;

/**
* Content of the citation. Must be clipped if longer than 480 characters.
* Content of the citation. (max length 160)
aacebo marked this conversation as resolved.
Show resolved Hide resolved
* Must be clipped if longer than 480 characters.
*/
abstract: string;

/**
* Used for icon; for now it is ignored.
*/
encodingFormat?: 'text/html';
encodingFormat?: 'application/vnd.microsoft.card.adaptive';
aacebo marked this conversation as resolved.
Show resolved Hide resolved

/**
* For now ignored, later used for icon
* Information about the citation’s icon.
*/
image?: string;
image?: {
'@type': 'ImageObject';

/**
* The image/icon name
*/
name: ClientCitationIconName;
};

/**
* Optional; set by developer
* Optional; set by developer. (max length 3) (max keyword length 28)
*/
keywords?: string[];

Expand Down
Loading
Loading