Skip to content

Commit

Permalink
Show ApplyButton's preparing state when a command is being prepared
Browse files Browse the repository at this point in the history
  • Loading branch information
lukemelia committed Feb 4, 2025
1 parent f718990 commit d9874b9
Show file tree
Hide file tree
Showing 9 changed files with 167 additions and 59 deletions.
6 changes: 4 additions & 2 deletions packages/ai-bot/lib/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ export async function sendMessageEvent(
export async function sendCommandEvent(
client: MatrixClient,
roomId: string,
messageBody: string,
functionCall: FunctionToolCall,
eventToUpdate: string | undefined,
) {
let messageObject = toMatrixMessageCommandContent(functionCall);
let messageObject = toMatrixMessageCommandContent(functionCall, messageBody);

if (messageObject !== undefined) {
return await sendMatrixEvent(
Expand Down Expand Up @@ -128,9 +129,10 @@ export async function sendErrorEvent(

export const toMatrixMessageCommandContent = (
functionCall: FunctionToolCall,
messageBody: string | undefined,
): IContent | undefined => {
let { arguments: payload } = functionCall;
const body = payload['description'] || 'Issuing command';
const body = messageBody || payload['description'] || 'Issuing command';
let messageObject: IContent = {
body: body,
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
Expand Down
15 changes: 12 additions & 3 deletions packages/ai-bot/lib/responder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,24 @@ export class Responder {
for (const toolCall of msg.tool_calls || []) {
log.debug('[Room Timeline] Function call', toolCall);
try {
const functionToolCall = this.deserializeToolCall(toolCall);
this.latestContent = [
this.latestContent,
functionToolCall.arguments['description'],
]
.filter(Boolean)
.join('\n\n');
let commandEventPromise = sendCommandEvent(
this.client,
this.roomId,
this.deserializeToolCall(toolCall),
this.initialMessageReplaced ? undefined : this.responseEventId,
this.latestContent,
functionToolCall,
this.responseEventId,
);
this.messagePromises.push(commandEventPromise);
await commandEventPromise;
this.initialMessageReplaced = true;
this.isStreamingFinished = true;
} catch (error) {
Sentry.captureException(error);
this.initialMessageReplaced = true;
Expand All @@ -159,7 +168,7 @@ export class Responder {
}

async finalize(finalContent: string | void | null | undefined) {
if (finalContent) {
if (finalContent && !this.isStreamingFinished) {
this.latestContent = cleanContent(finalContent);
this.isStreamingFinished = true;
await this.sendMessageEventWithDebouncing();
Expand Down
46 changes: 26 additions & 20 deletions packages/ai-bot/tests/responding-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ module('Responding', (hooks) => {
);
});

test('Sends tool call event separately when content is sent before tool call', async () => {
test('Sends tool call event and adds to event when content is sent before tool call', async () => {
const patchArgs = {
description: 'A new thing',
attributes: {
Expand Down Expand Up @@ -303,13 +303,37 @@ module('Responding', (hooks) => {
assert.equal(
sentEvents.length,
3,
'Thinking message, and tool call event should be sent',
'Thinking message, and content, and tool call event should be sent',
);
assert.equal(
sentEvents[0].content.body,
thinkingMessage,
'Thinking message should be sent first',
);
assert.notOk(
sentEvents[0].content['m.relates_to'],
'The tool call event should not replace any message',
);
assert.equal(
sentEvents[1].content.body,
'some content',
'Content message should be sent next',
);
assert.strictEqual(
sentEvents[1].content['m.relates_to']?.event_id,
sentEvents[0].eventId,
'The content event should replace the initial message',
);
assert.equal(
sentEvents[2].content.body,
'some content\n\nA new thing',
'Content message plus function description should be sent next',
);
assert.strictEqual(
sentEvents[2].content['m.relates_to']?.event_id,
sentEvents[0].eventId,
'The command event should replace the initial message',
);
assert.deepEqual(
JSON.parse(sentEvents[2].content.data),
{
Expand All @@ -332,24 +356,6 @@ module('Responding', (hooks) => {
},
'Tool call event should be sent with correct content',
);
assert.notOk(
sentEvents[2].content['m.relates_to'],
'The tool call event should not replace any message',
);

assert.equal(
sentEvents[1].content.body,
'some content',
'Content event should be sent',
);
assert.deepEqual(
sentEvents[1].content['m.relates_to'],
{
rel_type: 'm.replace',
event_id: '0',
},
'The content event should replace the thinking message',
);
});

test('Updates message type to command when tool call is in progress', async () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/host/app/components/ai-assistant/apply-button/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
@kind='secondary-dark'
@size='small'
class='apply-button'
tabindex='-1'
{{setCssVar boxel-button-text-color='var(--boxel-200)'}}
data-test-apply-state='preparing'
...attributes
Expand Down Expand Up @@ -93,6 +94,12 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
border: 0;
min-width: 74px;
}
.state-indicator.preparing .apply-button:hover,
.state-indicator.preparing .apply-button:focus {
--boxel-button-color: inherit;
filter: none;
cursor: not-allowed;
}
.state-indicator.preparing::before {
content: '';
Expand Down Expand Up @@ -150,6 +157,12 @@ const AiAssistantApplyButton: TemplateOnlyComponent<Signature> = <template>
background-color: var(--boxel-error-200);
border-color: var(--boxel-error-200);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</template>;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { TemplateOnlyComponent } from '@ember/component/template-only';

import ApplyButton from '../ai-assistant/apply-button';

interface Signature {
Element: HTMLDivElement;
}

const RoomMessageCommand: TemplateOnlyComponent<Signature> = <template>
<div ...attributes>
<div class='command-button-bar'>
<ApplyButton @state='preparing' data-test-command-apply='preparing' />
</div>
</div>

{{! template-lint-disable no-whitespace-for-layout }}
{{! ignore the above error because ember-template-lint complains about the whitespace in the multi-line comment below }}
<style scoped>
.command-button-bar {
display: flex;
justify-content: flex-end;
gap: var(--boxel-sp-xs);
margin-top: var(--boxel-sp);
}
</style>
</template>;

export default RoomMessageCommand;
3 changes: 3 additions & 0 deletions packages/host/app/components/matrix/room-message.gts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { type CardDef } from 'https://cardstack.com/base/card-api';
import AiAssistantMessage from '../ai-assistant/message';
import { aiBotUserId } from '../ai-assistant/panel';

import PreparingRoomMessageCommand from './preparing-room-message-command';
import RoomMessageCommand from './room-message-command';

interface Signature {
Expand Down Expand Up @@ -137,6 +138,8 @@ export default class RoomMessage extends Component<Signature> {
@failedCommandState={{this.failedCommandState}}
@isError={{bool this.errorMessage}}
/>
{{else if @message.isPreparingCommand}}
<PreparingRoomMessageCommand />
{{/if}}
</AiAssistantMessage>
{{/if}}
Expand Down
72 changes: 38 additions & 34 deletions packages/host/app/lib/matrix-classes/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,15 @@ export default class MessageBuilder {
message.attachedCardIds = this.attachedCardIds;
} else if (event.content.msgtype === 'm.text') {
message.isStreamingFinished = !!event.content.isStreamingFinished; // Indicates whether streaming (message updating while AI bot is sending more content into the message) has finished
} else if (
event.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE &&
event.content.data.toolCall
) {
} else if (event.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE) {
message.formattedMessage = this.formattedMessageForCommand;
message.command = this.buildMessageCommand(message);
message.isStreamingFinished = true;
const command = this.buildMessageCommand(message);
if (command) {
message.command = command;
} else {
message.isPreparingCommand = true;
}
message.isStreamingFinished = !!event.content.isStreamingFinished; // Indicates whether streaming (message updating while AI bot is sending more content into the message) has finished
}
return message;
}
Expand All @@ -159,16 +161,16 @@ export default class MessageBuilder {
message.updated = new Date();

if (this.event.content.msgtype === APP_BOXEL_COMMAND_MSGTYPE) {
if (!message.command) {
message.command = this.buildMessageCommand(message);
const latestCommand = this.buildMessageCommand(message);
if (!message.command && latestCommand) {
message.command = latestCommand;
message.isPreparingCommand = false;
} else if (latestCommand && message.command) {
message.command.name = latestCommand.name;
message.command.payload = latestCommand.payload;
} else {
message.isPreparingCommand = true;
}

message.isStreamingFinished = true;
message.formattedMessage = this.formattedMessageForCommand;

let command = this.event.content.data.toolCall;
message.command.name = command.name;
message.command.payload = command.arguments;
}
}

Expand All @@ -177,7 +179,7 @@ export default class MessageBuilder {
message.command = this.buildMessageCommand(message);
}

if (this.builderContext.commandResultEvent) {
if (this.builderContext.commandResultEvent && message.command) {
let event = this.builderContext.commandResultEvent;
message.command.commandStatus = event.content['m.relates_to']
.key as CommandStatus;
Expand All @@ -197,27 +199,29 @@ export default class MessageBuilder {
return (
e.type === APP_BOXEL_COMMAND_RESULT_EVENT_TYPE &&
r.rel_type === 'm.annotation' &&
(r.event_id === event!.content.data.eventId ||
r.event_id === event!.event_id ||
(r.event_id === event!.event_id ||
r.event_id === this.builderContext.effectiveEventId)
);
}) as CommandResultEvent | undefined);

let command = event.content.data.toolCall;
let messageCommand = new MessageCommand(
message,
command.id,
command.name,
command.arguments,
this.builderContext.effectiveEventId,
(commandResultEvent?.content['m.relates_to']?.key ||
'ready') as CommandStatus,
commandResultEvent?.content.msgtype ===
APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE
? commandResultEvent.content.data.cardEventId
: undefined,
getOwner(this)!,
);
return messageCommand;
if (event.content.isStreamingFinished !== false && event.content.data) {
let command = event.content.data.toolCall;
let messageCommand = new MessageCommand(
message,
command.id,
command.name,
command.arguments,
this.builderContext.effectiveEventId,
(commandResultEvent?.content['m.relates_to']?.key ||
'ready') as CommandStatus,
commandResultEvent?.content.msgtype ===
APP_BOXEL_COMMAND_RESULT_WITH_OUTPUT_MSGTYPE
? commandResultEvent.content.data.cardEventId
: undefined,
getOwner(this)!,
);
return messageCommand;
}
return null;
}
}
1 change: 1 addition & 0 deletions packages/host/app/lib/matrix-classes/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export class Message implements RoomMessageInterface {
@tracked formattedMessage: string;
@tracked message: string;
@tracked command?: MessageCommand | null;
@tracked isPreparingCommand?: boolean;
@tracked isStreamingFinished?: boolean;

attachedCardIds?: string[] | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2094,6 +2094,48 @@ module('Integration | ai-assistant-panel', function (hooks) {
.hasValue('This is 1st sentence \n\nThis is 2nd sentence');
});

test('when a command is being prepared, apply button is shown in preparing state', async function (assert) {
await setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`);
await renderComponent(
class TestDriver extends GlimmerComponent {
<template>
<OperatorMode @onClose={{noop}} />
<CardPrerender />
</template>
},
);
await waitFor('[data-test-person="Fadhlan"]');
let roomId = createAndJoinRoom('@testuser:staging', 'test room 1');
let initialEventId = simulateRemoteMessage(roomId, '@aibot:localhost', {
msgtype: APP_BOXEL_MESSAGE_MSGTYPE,
body: 'Changing',
formatted_body: 'Changing',
format: 'org.matrix.custom.html',
isStreamingFinished: false,
});
simulateRemoteMessage(roomId, '@aibot:localhost', {
msgtype: APP_BOXEL_COMMAND_MSGTYPE,
body: 'Changing first name to Evie',
formatted_body: 'Changing first name to Evie',
format: 'org.matrix.custom.html',
isStreamingFinished: false,
'm.relates_to': {
rel_type: 'm.replace',
event_id: initialEventId,
},
});

await settled();

await click('[data-test-open-ai-assistant]');
await waitFor('[data-test-room-name="test room 1"]');
assert.dom('[data-test-message-idx]').exists({ count: 1 });
await waitFor('[data-test-message-idx="0"] [data-test-command-apply]');
assert
.dom('[data-test-message-idx="0"] [data-test-command-apply="preparing"]')
.exists();
});

test('after command is issued, a reaction event will be dispatched', async function (assert) {
await setCardInOperatorModeState(`${testRealmURL}Person/fadhlan`);
await renderComponent(
Expand Down

0 comments on commit d9874b9

Please sign in to comment.